Структура и интерпретация компьютерных программ (pdf)

-  Структура и интерпретация компьютерных программ  4.02 Мб (скачать pdf) (скачать pdf+fbd)  (читать)  (читать постранично) - Харольд Абельсон

Книга в формате pdf! Изображения и текст могут не отображаться!


Настройки текста:



Harold Abelson
and

Gerald Jay Sussman
with

Julie Sussman

Structure and Interpretation
of Computer Programs

The MIT Press
Cambridge, Massatchusetts
New York

The McGraw-Hill Companies, Inc.
St.Louis
San Francisco
Montreal

London, England
Toronto

Харольд Абельсон
Джеральд Джей Сассман
при участии

Джули Сассман

Структура и интерпретация
компьютерных программ
Добросвет, 2006

3

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

“Мне кажется, чрезвычайно важно, чтобы мы, занимаясь информатикой, получали радость от общения с компьютером. С самого начала это было громадным удовольствием. Конечно, время от времени встревали заказчики, и
через какое-то время мы стали серьезно относиться к их жалобам. Нам стало
казаться, что мы вправду отвечаем за то, чтобы эти машины использовались
успешно и безошибочно. Я не думаю, что это так. Я считаю, что мы отвечаем
за то, чтобы их тренировать, указывать им новые направления и поддерживать уют в доме. Я надеюсь, что информатика никогда не перестанет быть
радостью. Я надеюсь, что мы не превратимся в миссионеров. Не надо чувствовать себя продавцом Библий. Таких в мире и так достаточно. То, что Вы
знаете о программировании, могут выучить и другие. Не думайте, что в ваших руках ключ к успешной работе с компьютерами. Что у Вас, как я думаю
и надеюсь, есть — это разум: способность увидеть в машине больше, чем Вы
видели, когда Вас впервые к ней подвели, увидеть, что Вы способны сделать
ее бóльшим.”
Алан Дж. Перлис (1 апреля 1922 – 7 февраля 1990)

Оглавление
Предисловие . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .

9

Предисловие ко второму изданию . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
Предисловие к первому изданию . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
Благодарности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
1. Построение абстракций с помощью процедур . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1. Элементы программирования . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1.1. Выражения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1.2. Имена и окружение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1.3. Вычисление комбинаций . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1.4. Составные процедуры . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1.5. Подстановочная модель применения процедуры . . . . . . . . . . . . . . . . . . . .
1.1.6. Условные выражения и предикаты . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1.7. Пример: вычисление квадратного корня методом Ньютона . . . . . . . . . .
1.1.8. Процедуры как абстракции типа «черный ящик» . . . . . . . . . . . . . . . . . . .
1.2. Процедуры и порождаемые ими процессы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.1. Линейные рекурсия и итерация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.2. Древовидная рекурсия . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.3. Порядки роста . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.4. Возведение в степень . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.5. Нахождение наибольшего общего делителя . . . . . . . . . . . . . . . . . . . . . . . .
1.2.6. Пример: проверка на простоту . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.3. Формулирование абстракций с помощью процедур высших порядков . . . . . .
1.3.1. Процедуры в качестве аргументов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.3.2. Построение процедур с помощью lambda . . . . . . . . . . . . . . . . . . . . . . . . .
1.3.3. Процедуры как обобщенные методы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.3.4. Процедуры как возвращаемые значения . . . . . . . . . . . . . . . . . . . . . . . . . . .

22
25
26
27
29
31
33
35
40
43
47
48
53
58
59
62
64
69
70
74
78
83

2. Построение абстракций с помощью данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.1. Введение в абстракцию данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.1.1. Пример: арифметические операции над рациональными числами . . . .
2.1.2. Барьеры абстракции . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .

90
93
93
97

5

Оглавление

6

2.2.

2.3.

2.4.

2.5.

2.1.3. Что значит слово «данные»? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
2.1.4. Расширенный пример: интервальная арифметика . . . . . . . . . . . . . . . . . . . 102
Иерархические данные и свойство замыкания . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
2.2.1. Представление последовательностей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
2.2.2. Иерархические структуры . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115
2.2.3. Последовательности как стандартные интерфейсы . . . . . . . . . . . . . . . . . . 120
2.2.4. Пример: язык описания изображений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132
Символьные данные . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 146
2.3.1. Кавычки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 146
2.3.2. Пример: символьное дифференцирование . . . . . . . . . . . . . . . . . . . . . . . . . . 149
2.3.3. Пример: представление множеств . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 154
2.3.4. Пример: деревья кодирования по Хаффману . . . . . . . . . . . . . . . . . . . . . . . 163
Множественные представления для абстрактных данных . . . . . . . . . . . . . . . . . . 170
2.4.1. Представления комплексных чисел . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 172
2.4.2. Помеченные данные . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 175
2.4.3. Программирование, управляемое данными, и аддитивность . . . . . . . . . 179
Системы с обобщенными операциями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186
2.5.1. Обобщенные арифметические операции . . . . . . . . . . . . . . . . . . . . . . . . . . . . 187
2.5.2. Сочетание данных различных типов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 191
2.5.3. Пример: символьная алгебра . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 198

3. Модульность, объекты и состояние . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 211
3.1. Присваивание и внутреннее состояние объектов . . . . . . . . . . . . . . . . . . . . . . . . . . 212
3.1.1. Внутренние переменные состояния . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
3.1.2. Преимущества присваивания . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 218
3.1.3. Издержки, связанные с введением присваивания . . . . . . . . . . . . . . . . . . . 221
3.2. Модель вычислений с окружениями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 227
3.2.1. Правила вычисления . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 228
3.2.2. Применение простых процедур . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231
3.2.3. Кадры как хранилище внутреннего состояния . . . . . . . . . . . . . . . . . . . . . . 234
3.2.4. Внутренние определения. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 238
3.3. Моделирование при помощи изменяемых данных . . . . . . . . . . . . . . . . . . . . . . . . . 241
3.3.1. Изменяемая списковая структура . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 242
3.3.2. Представление очередей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 250
3.3.3. Представление таблиц . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 254
3.3.4. Имитация цифровых схем . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 260
3.3.5. Распространение ограничений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 271
3.4. Параллелизм: время имеет значение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 281
3.4.1. Природа времени в параллельных системах . . . . . . . . . . . . . . . . . . . . . . . . 284
3.4.2. Механизмы управления параллелизмом . . . . . . . . . . . . . . . . . . . . . . . . . . . . 287
3.5. Потоки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 298
3.5.1. Потоки как задержанные списки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 299
3.5.2. Бесконечные потоки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 307
3.5.3. Использование парадигмы потоков . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 314
3.5.4. Потоки и задержанное вычисление . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 324

Оглавление

7

3.5.5. Модульность функциональных программ
и модульность объектов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 330
4. Метаязыковая абстракция . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 335
4.1. Метациклический интерпретатор . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 338
4.1.1. Ядро интерпретатора . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 339
4.1.2. Представление выражений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 343
4.1.3. Структуры данных интерпретатора . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 350
4.1.4. Выполнение интерпретатора как программы . . . . . . . . . . . . . . . . . . . . . . . . 354
4.1.5. Данные как программы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 357
4.1.6. Внутренние определения. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 360
4.1.7. Отделение синтаксического анализа от выполнения . . . . . . . . . . . . . . . . 365
4.2. Scheme с вариациями: ленивый интерпретатор . . . . . . . . . . . . . . . . . . . . . . . . . . . . 369
4.2.1. Нормальный порядок вычислений и аппликативный порядок вычислений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 370
4.2.2. Интерпретатор с ленивым вычислением . . . . . . . . . . . . . . . . . . . . . . . . . . . . 372
4.2.3. Потоки как ленивые списки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 379
4.3. Scheme с вариациями —
недетерминистское вычисление . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 381
4.3.1. Amb и search . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 383
4.3.2. Примеры недетерминистских программ . . . . . . . . . . . . . . . . . . . . . . . . . . . . 386
4.3.3. Реализация amb-интерпретатора . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 394
4.4. Логическое программирование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 404
4.4.1. Дедуктивный поиск информации . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 407
4.4.2. Как действует система обработки запросов . . . . . . . . . . . . . . . . . . . . . . . . 417
4.4.3. Является ли логическое программирование математической логикой? 425
4.4.4. Реализация запросной системы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 429
5. Вычисления на регистровых машинах . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 450
5.1. Проектирование регистровых машин . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 451
5.1.1. Язык для описания регистровых машин . . . . . . . . . . . . . . . . . . . . . . . . . . . 454
5.1.2. Абстракция в проектировании машин . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 457
5.1.3. Подпрограммы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 459
5.1.4. Реализация рекурсии с помощью стека . . . . . . . . . . . . . . . . . . . . . . . . . . . . 463
5.1.5. Обзор системы команд . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 471
5.2. Программа моделирования регистровых машин . . . . . . . . . . . . . . . . . . . . . . . . . . . 472
5.2.1. Модель машины . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 473
5.2.2. Ассемблер . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 476
5.2.3. Порождение исполнительных процедур для команд . . . . . . . . . . . . . . . . . 480
5.2.4. Отслеживание производительности машины . . . . . . . . . . . . . . . . . . . . . . . 486
5.3. Выделение памяти и сборка мусора . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 489
5.3.1. Память как векторы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 489
5.3.2. Иллюзия бесконечной памяти . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 494
5.4. Вычислитель с явным управлением . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 500
5.4.1. Ядро вычислителя с явным управлением . . . . . . . . . . . . . . . . . . . . . . . . . . 502
5.4.2. Вычисление последовательностей и хвостовая рекурсия . . . . . . . . . . . . 507

5.4.3. Условные выражения, присваивания и определения . . . . . . . . . . . . . . . . . 510
5.4.4. Запуск вычислителя . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 512
5.5. Компиляция . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 517
5.5.1. Структура компилятора. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 520
5.5.2. Компиляция выражений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 524
5.5.3. Компиляция комбинаций . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 530
5.5.4. Сочетание последовательностей команд . . . . . . . . . . . . . . . . . . . . . . . . . . . . 536
5.5.5. Пример скомпилированного кода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 539
5.5.6. Лексическая адресация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 547
5.5.7. Связь скомпилированного кода с вычислителем . . . . . . . . . . . . . . . . . . . . 551
Литература . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 558
Предметный указатель . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 566

Предисловие
Программированием занимаются учителя, генералы, диетологи, психологи и родители. Программированию подвергаются армии, ученики и некоторые виды обществ. При
решении крупных задач приходится применять последовательно множество программ,
бо́льшая часть которых возникает прямо в процессе решения. Эти программы изобилуют деталями, относящимися к той конкретной задаче, которую они решают. Если же
Вы хотите оценить программирование как интеллектуальную деятельность особого рода, то Вам следует обратиться к программированию компьютеров; читайте и пишите
компьютерные программы — много программ. Не так уж важно, что будет в них написано и как они будут применяться. Важно то, насколько хорошо они работают и как
гладко стыкуются с другими программами при создании еще более крупных программ.
Программист должен равно стремиться и к совершенству в деталях, и к соразмерности
сложного целого. В книге, которую Вы держите в руках, словом «программирование» мы
будем обозначать прежде всего создание, выполнение и изучение программ, написанных
на одном из диалектов языка Лисп и предназначенных для выполнения на цифровом
компьютере. Использование Лиспа не ограничивает нас в том, что́ мы можем описать в
наших программах, — лишь в способе их выражения.
Продвигаясь по материалу этой книги, мы будем встречаться с тремя группами явлений: человеческий разум, совокупности компьютерных программ и компьютер. Всякая
компьютерная программа — это порожденная человеческим разумом модель реального
либо умозрительного процесса. Эти процессы, возникающие из нашего опыта и мысли,
многочисленны, сложны в деталях, и мы всегда понимаем их лишь частично. Редко
бывает так, что компьютерные программы отображают их к нашему окончательному
удовлетворению. Таким образом, хотя наши программы представляют собой тщательно сработанные дискретные совокупности символов, мозаики переплетенных функций,
они непрерывно развиваются: мы изменяем их по мере того, как наше восприятие модели приобретает все большую глубину, расширяется и обобщается, до тех пор, пока
модель не достигнет, наконец, метастабильного состояния в рамках следующей модели,
над которой нам предстоит биться. Радостное возбуждение, сопутствующее компьютерному программированию, происходит из постоянного раскрытия в голове и в компьютере
все новых выраженных в виде программ механизмов и из взрыва восприятия, который
они порождают. Искусство выражает наши мечты. Компьютер исполняет их под видом
программ!
При всей своей мощности, компьютер требователен и придирчив. Ему нужны верные
программы, и то, что мы хотим ему сказать, должно быть выражено точно в каждой
мелочи. Как и при всякой другой работе с символами, мы убеждаемся в правильности

10

Предисловие

программ через доказательство. Самому Лиспу можно сопоставить семантику (между
прочим, тоже модель), и если функцию программы можно выразить, скажем, в терминах исчисления предикатов, то логические методы позволят нам вывести формальное
доказательство ее корректности. К сожалению, когда программы становятся большими и сложными, что с ними всегда и происходит, адекватность, непротиворечивость и
корректность самих спецификаций становится предметом сомнений, так что большие
программы редко сопровождаются полными формальными доказательствами корректности. Поскольку большие программы вырастают из малых, нам необходимо обзавестись
арсеналом программных структур, в правильности которых мы можем быть уверены —
их можно назвать идиомами — и научиться объединять их в структуры большего размера с помощью организационных методов, ценность которых также доказана. Эти методы
подробно обсуждаются в книге, и их понимание существенно для участия в прометеевском предприятии под названием «программирование». Для умения создавать большие,
значительные программы нет лучшего помощника, чем свободное владение мощными
организационными методами. И наоборот: затраты, связанные с написанием больших
программ, побуждают нас изобретать новые методы уменьшения веса функций и деталей, входящих в эти программы.
В отличие от программ, компьютеры должны повиноваться законам физики. Если мы
хотим, чтобы они работали быстро — по нескольку наносекунд на смену состояния, —
электроны в их цепях не должны проходить большие расстояния (более полуметра).
При этом тесно сконцентрированные в пространстве приборы излучают тепло, которое
нужно куда-то отводить: так развилось изысканное инженерное искусство, призванное
находить равновесие между обилием функций и плотностью расположения устройств.
Так или иначе, аппаратура всегда работает ниже того уровня, на котором мы бы хотели программировать. Процессы, посредством которых наши программы на Лиспе переводятся в «машинные» программы, сами являются абстрактными моделями, которые
мы воплощаем в программах. Их изучение и реализация многое дают для понимания
организационных методов, направленных на программирование произвольных моделей.
Разумеется, так можно смоделировать и сам компьютер. Подумайте об этом: поведение
мельчайшего переключателя моделируется квантовой механикой, которая описывается
дифференциальными уравнениями, точное поведение которых фиксируется в численных
приближениях, представленных в виде компьютерных программ, которые выполняются
на компьютере, составленном из ... — и так без конца!
Раздельное выделение трех групп явлений — не просто вопрос тактического удобства. Хотя эти группы и остаются, как говорится, в голове, но, проводя это разделение,
мы позволяем потоку символов между тремя группами двигаться быстрее. В человеческом опыте с этим потоком по богатству, живости и обилию возможностей сравнится
разве что сама эволюция жизни. Отношения между разумом человека, программами и
компьютером в лучшем случае метастабильны. Компьютерам никогда не хватает мощности и быстродействия. Каждый новый прорыв в технологии производства аппаратуры
ведет к появлению более масштабных программных проектов, новых организационных
принципов и к обогащению абстрактных моделей. Пусть каждый читатель время от времени спрашивает себя: «А зачем, к чему все это?» — только не слишком часто, чтобы
удовольствие от программирования не сменилось горечью философского тупика.

Предисловие

11

Из тех программ, которые мы пишем, некоторые (но всегда меньше, чем хотелось бы)
решают точные математические задачи, такие, как сортировка последовательности чисел
или нахождение их максимума, проверка числа на простоту или вычисление квадратного
корня. Такие программы называются алгоритмами, и об их оптимальном поведении известно довольно много, особенно в том, что касается двух важных параметров: времени
выполнения и потребления памяти. Программист должен владеть хорошими алгоритмами и идиомами. Несмотря на то, что некоторые программы сопротивляются точной
спецификации, в обязанности программиста входит оценивать их производительность и
все время пытаться ее улучшить.
Лисп — ветеран, он используется уже около четверти века. Среди живых языков
программирования старше него только Фортран. Эти два языка обслуживали нужды
важных прикладных областей: Фортран — естественно-научных и технических вычислений, а Лисп — искусственного интеллекта. Обе эти области по-прежнему важны, а
программисты, работающие в них, настолько привязаны к этим двум языкам, что Лисп
и Фортран вполне могут остаться в деле еще по крайней мере на четверть столетия.
Лисп изменяется. Scheme, его диалект, используемый в этой книге, развился из первоначального Лиспа и отличается от него в некоторых важных отношениях: в частности,
используются статические области связывания переменных, а функции могут возвращать в качестве значений другие функции. По семантической структуре Scheme так же
близка к Алголу 60, как и к ранним вариантам Лиспа. Алгол 60, который уже никогда
не будет живым языком, продолжает жить в генах Scheme и Паскаля. Пожалуй, трудно
найти две более разные культуры программирования, чем те, что образовались вокруг
этих двух языков и используют их в качестве единой валюты. Паскаль служит для построения пирамид — впечатляющих, захватывающих статических структур, создаваемых
армиями, которые укладывают на места тяжелые плиты. При помощи Лиспа порождаются организмы — впечатляющие, захватывающие динамические структуры, создаваемые
командами, которые собирают их из мерцающих мириад более простых организмов. Организующие принципы в обоих случаях остаются одни и те же, за одним существенным
исключением: программист, пишущий на Лиспе, располагает на порядок большей творческой свободой в том, что касается функций, которые он создает для использования
другими. Программы на Лиспе населяют библиотеки функциями, которые оказываются
настолько полезными, что они переживают породившие их приложения. Таким ростом
полезности мы во многом обязаны списку — исконной лисповской структуре данных.
Простота структуры списков и естественность их использования отражаются в удивительной общности функций. В Паскале обилие объявляемых структур данных ведет к
специализации функций, которая сдерживает и наказывает случайное взаимодействие
между ними. Лучше иметь 100 функций, которые работают с одной структурой данных, чем 10 функций, работающих с 10 структурами. В результате пирамиде приходится
неподвижно стоять тысячелетиями; организм же будет развиваться или погибнет.
Чтобы увидеть эту разницу, сравните подачу материала и упражнения в этой книге с
тем, что Вы найдете в любом вводном тексте, авторы которого используют Паскаль. Не
поддавайтесь ошибочному впечатлению, будто этот текст может усвоить лишь студент
MIT — представитель специфической породы, которая только там и встречается. Нет;
именно такова должна быть всякая серьезная книга, посвященная программированию на
Лиспе, вне зависимости от того, где и кто по ней учится.
Учтите, что это текст о программировании, в отличие от большинства книг по Лиспу,

12

Предисловие

которые используются для подготовки работников в области искусственного интеллекта. В конце концов, основные программистские заботы вычислительной инженерии и
искусственного интеллекта стремятся к взаимопроникновению по мере того, как соответствующие системы увеличиваются в объеме. Это объясняет рост интереса к Лиспу
за пределами искусственного интеллекта.
Как и можно было ожидать, глядя на цели, которые ставят перед собой исследователи
в области искусственного интеллекта, область эта порождает множество значительных
программистских задач. В других программистских культурах такой наплыв задач рождает новые языки. В самом деле, в любой большой программной задаче один из важных
принципов организации состоит в том, чтобы ограничить и изолировать потоки информации в отдельных модулях задачи, изобретая для этого язык. По мере приближения
к границам системы, где мы — люди — взаимодействуем чаще всего, эти языки обычно становятся все менее примитивными. В результате такие системы содержат сложные
функции по обработке языка, повторенные по многу раз. У Лиспа же синтаксис и семантика настолько просты, что синтаксический разбор можно считать элементарной задачей.
Таким образом, методы синтаксического разбора не играют почти никакой роли в программах на Лиспе, и построение языковых процессоров редко служит препятствием для
роста и изменения больших Лисп-систем. Наконец, именно эта простота синтаксиса и
семантики возлагает бремя свободы на всех программистов на Лиспе. Никакую программу на Лиспе больше, чем в несколько строк длиной, невозможно написать, не населив
ее самостоятельными функциями. Находите новое и приспосабливайте; складывайте и
стройте новыми способами! Я поднимаю тост за программиста на Лиспе, укладывающего
свои мысли в гнезда скобок.
Алан Дж. Перлис
Нью-Хейвен, Коннектикут

Предисловие ко второму
изданию
Возможно ли, что программы не похожи
ни на что другое, что они предназначены
на выброс; что вся штука состоит в том,
чтобы всегда видеть в них мыльный
пузырь?
Алан Дж. Перлис

Материал этой книги был основой вводного курса по информатике в MIT начиная с
1980 года. К тому времени, как было выпущено первое издание, мы преподавали этот
материал в течение четырех лет, и прошло еще двенадцать лет до появления второго
издания. Нам приятно, что наша работа была широко признана и включена в другие
тексты. Мы видели, как наши ученики черпали идеи и программы из этой книги и
на их основе строили новые компьютерные системы и языки. Буквально по старому
талмудическому каламбуру, наши ученики стали нашими строителями. Мы рады, что у
нас такие одаренные ученики и такие превосходные строители.
Готовя это издание, мы включили в него сотни поправок, которые нам подсказали
как наш собственный преподавательский опыт, так и советы коллег из MIT и других
мест. Мы заново спроектировали большинство основных программных систем в этой
книге, включая систему обобщенной арифметики, интерпретаторы, имитатор регистровых машин и компилятор; кроме того, мы переписали все примеры программ так, чтобы
любая реализация Scheme, соответствующая стандарту Scheme IEEE (IEEE 1990), была
способна выполнять этот код.
В этом издании подчеркиваются несколько новых тем. Самая важная из них состоит
в том, что центральную роль в вычислительных моделях играют различные подходы ко
времени: объекты, обладающие состоянием, параллельное программирование, функциональное программирование, ленивые вычисления и недетерминистское программирование. Мы включили в текст новые разделы по параллельным вычислениям и недетерминизму и постарались интегрировать эту тему в материал книги на всем ее протяжении.
Первое издание книги почти точно следовало программе нашего односеместрового
курса в MIT. Рассмотреть весь материал, включая то, что добавлено во втором издании, в течение семестра будет невозможно, так что преподавателю придется выбирать.
В нашей собственной практике мы иногда пропускаем раздел про логическое програм-

14

Предисловие ко второму изданию

мирование (раздел 4.4); наши студенты используют имитатор регистровых машин, но
мы не описываем его реализацию (раздел 5.2); наконец, мы даем лишь беглый обзор
компилятора (раздел 5.5). Даже в таком виде курс остается интенсивным. Некоторые
преподаватели предпочтут ограничиться первыми тремя или четырьмя главами, оставляя прочий материал для последующих курсов.
Сайт World Wide Web http://mitpress.mit.edu/sicp предоставляет поддержку
пользователям этой книги. Там есть программы из книги, простые задания по программированию, сопроводительные материалы и реализации диалекта Лиспа Scheme.∗

∗ В настоящее время (август 2005 г.) на сайте имеется также полный текст англоязычного издания. — прим.
перев.

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

«Структура и интерпретация компьютерных программ» — это вводный курс по информатике в Массачусетском Технологическом институте (MIT). Он обязателен для всех
студентов MIT на специальностях «электротехника» и «информатика», как одна из четырех частей «общей базовой программы обучения», которая включает еще два курса по
электрическим схемам и линейным системам, а также курс по проектированию цифровых
систем. Мы принимали участие в развитии этого курса начиная с 1978 года и преподавали этот материал в его нынешней форме начиная с осени 1980 года шестистам–семистам
студентам в год. Большая часть этих студентов не имела почти или совсем никакого формального образования в области вычислительной техники, хотя у многих была
возможность общения с компьютерами, а некоторые обладали значительным опытом в
программировании либо проектировании аппаратуры.
Построение этого вводного курса по информатике отражает две основные задачи. Вопервых, мы хотим привить слушателям идею, что компьютерный язык — это не просто
способ заставить компьютер производить вычисления, а новое формальное средство выражения методологических идей. Таким образом, программы должны писаться для того,
чтобы их читали люди, и лишь во вторую очередь для выполнения машиной. Во-вторых,
мы считаем, что основной материал, на который должен быть направлен курс этого
уровня, — не синтаксис определенного языка программирования, не умные алгоритмы

16

Предисловие к первому изданию

для эффективного вычисления определенных функций, даже не математический анализ
алгоритмов и оснований программирования, но методы управления интеллектуальной
сложностью больших программных систем.
Наша цель — развить в студентах, проходящих этот курс, хороший вкус к элементам
стиля и эстетике программирования. Они должны овладеть основными методами управления сложностью в большой системе, уметь прочитать 50-ти страничную программу,
если она написана в хорошем стиле. Они должны в каждый данный момент понимать,
чего сейчас не следует читать и что сейчас не нужно понимать. Они не должны испытывать страха перед модификацией программы, сохраняя при этом дух и стиль исходного
автора.
Все эти умения ни в коем случае не исчерпываются компьютерным программированием. Методы, которым мы учим и из которых мы черпаем, одни и те же в любом техническом проектировании. Мы управляем сложностью с помощью построения абстракций,
скрывающих, когда это нужно, детали. Мы управляем сложностью путем установления
стандартных интерфейсов, которые позволяют нам строить системы из единообразных,
хорошо понимаемых кусков способом «смеси и стыковки». Мы управляем сложностью с
помощью построения новых языков для описания проекта, каждый из которых концентрирует внимание на определенных деталях проекта и уводит его от других.
В основе нашего подхода к предмету лежит убеждение, что «компьютерная наука» не
является наукой и что ее значение мало связано с компьютерами. Компьютерная революция — это революция в том, как мы мыслим и как мы выражаем наши мысли. Сущность
этих изменений состоит в появлении дисциплины, которую можно назвать компьютерной эпистемологией, — исследования структуры знания с императивной точки зрения,
в противоположность более декларативной точке зрения классических математических
дисциплин. Математика дает нам структуру, в которой мы можем точно описывать понятия типа «что такое». Вычислительная наука дает нам структуру, в которой мы можем
точно описывать понятия типа «как».
В преподавании мы используем диалект языка программирования Лисп. Мы не учим
формальной стороне языка, поскольку в этом не возникает нужды. Мы просто его используем, и студенты схватывают его за несколько дней. В этом состоит одно из больших преимуществ лиспоподобных языков: в них очень мало способов строить составные
выражения и нет почти никакой синтаксической структуры. Все формальные детали могут быть описаны за час, как правила шахмат. Спустя некоторое время мы забываем о
формальных свойствах языка (поскольку их нет) и продолжаем говорить о настоящих
вопросах; определяем, что именно мы хотим вычислить, как мы будем разбивать задачу
на куски разумного размера и как потом будем работать с этими кусками. Еще одно преимущество Лиспа состоит в том, что он поддерживает (но не навязывает) больше крупномасштабных стратегий разбиения программ на модули, чем любой другой известный нам
язык. Можно строить абстракции процедур и данных, можно использовать функции высших порядков, чтобы охватить общие шаблоны их использования, можно моделировать
локальное состояние с использованием присваивания и изменения данных, можно связывать части программы с помощью потоков и задержанных вычислений, и можно с легкостью реализовывать встроенные языки. Все это включено в диалоговое окружение с превосходной поддержкой пошагового проектирования, построения, тестирования и отладки
программ. Мы благодарны всем поколениям кудесников Лиспа начиная с Джона Маккарти, которые создали замечательный инструмент непревзойденной силы и красоты.

Предисловие к первому изданию

17

Scheme, тот диалект Лиспа, который мы используем, пытается совместить силу и
красоту Лиспа и Алгола. От Лиспа мы берем метаязыковую мощь, которой он обязан
простоте своего синтаксиса, единообразное представление программ как объектов данных, выделение данных из кучи с последующей их утилизацией сборщиком мусора.
От Алгола мы берем лексическую область действия и блоковую структуру, подаренные
нам первопроходцами проектирования языков программирования из комитета по Алголу.
Мы хотим упомянуть Джона Рейнольдса и Питера Ландина, открывших связь Чёрчева лямбда-исчисления со структурой языков программирования. Мы также отдаем дань
признательности математикам, разведавшим эту область за десятилетия до появления
на сцене компьютеров. Среди этих первопроходцев были Алонсо Чёрч, Беркли Россер,
Стефен Клини и Хаскелл Карри.

Благодарности
Мы хотели бы поблагодарить множество людей, которые помогли нам создать эту
книгу и этот курс.
Наш курс — очевидный интеллектуальный потомок «6.321», замечательного курса по
компьютерной лингвистике и лямбда-исчислению, который читали в MIT в конце 60-х
Джек Уозенкрафт и Артур Эванс мл.
Мы очень обязаны Роберту Фано, который реорганизовал вводную программу MIT
по электротехнике и информатике, сосредоточившись на принципах технического проектирования. Он вдохновил нас на это предприятие и написал первую программу курса,
из которого развилась эта книга.
Стиль и эстетика программирования, которые мы пытаемся привить читателю, во
многом были разработаны совместно с Гаем Льюисом Стилом мл., который вместе с
Джеральдом Джеем Сассманом участвовал в первоначальной разработке языка Scheme.
В дополнение к этому Дэвид Тёрнер, Питер Хендерсон, Дэн Фридман, Дэвид Уайз
и Уилл Клингер научили нас многим из приемов функционального программирования,
которые излагаются в данной книге.
Джон Мозес научил нас структурировать большие системы. Благодаря его опыту с
системой символьных вычислений Macsyma мы стали понимать, что необходимо избегать усложненности структур управления и в первую очередь заботиться о такой организации данных, которая отражает реальную структуру моделируемого мира
Марвин Минский и Сеймур Пэйперт сильно повлияли на формирование нашего подхода к программированию и к его месту в нашей интеллектуальной жизни. Благодаря им
мы понимаем, что вычисление дает нам средство выражения и исследования мыслей, которые иначе были бы слишком сложны, чтобы с ними можно было точно работать. Они
подчеркивают, что способность писать и изменять программы дает студенту мощное
средство, с помощью которого исследование становится естественной деятельностью.
Кроме того, мы полностью согласны с Аланом Перлисом в том, что программирование — это огромное удовольствие и что нам нужно стараться поддерживать радость
программирования. Часть этой радости приходит от наблюдения за работой великих мастеров. Нам выпало счастье быть учениками у ног Билла Госпера и Ричарда Гринблатта.
Трудно перечислить всех тех, кто принял участие в развитии программы нашего курса. Мы благодарим всех лекторов, инструкторов и тьюторов, которые работали с нами в
прошедшие пятнадцать лет и потратили много часов сверхурочной работы на наш предмет, особенно Билла Сиберта, Альберта Мейера, Джо Стоя, Рэнди Дэвиса, Луи Брэйда,
Эрика Гримсона, Рода Брукса, Линна Стейна и Питера Соловитца. Мы бы хотели особо
отметить выдающийся педагогический вклад Франклина Турбака, который теперь пре-

Благодарности

19

подает в Уэллесли: его работа по обучению младшекурсников установила стандарт, на
который мы все можем равняться. Мы благодарны Джерри Сальтцеру и Джиму Миллеру, которые помогли нам бороться с тайнами параллельных вычислений, а также Питеру
Соловитцу и Дэвиду Макаллестеру за их вклад в представление недетерминистских
вычислений в главе 4.
Много людей вложило немалый труд в преподавание этого материала и в других
университетах. Вот некоторые из тех, с кем мы тесно общались в работе: это Джекоб
Кацнельсон в Технионе, Хэрди Майер в Калифорнийском университете в Ирвине, Джо
Стой в Оксфорде, Элиша Сэкс в университете Пердью и Ян Коморовский в Норвежском
университете Науки и Техники. Мы гордимся коллегами, которые получили награды
за адаптацию этого предмета в других университетах: это Кеннет Йип в Йеле, Брайан
Харви в Калифорнийском университете в Беркли и Дон Хаттенлохер в Корнелле.
Эл Мойе дал нам возможность прочитать этот материал инженерам компании ХьюлеттПаккард и устроил производство видеоверсии этих лекций. Мы хотели бы поблагодарить
одаренных преподавателей — в особенности Джима Миллера, Билла Сиберта и Майка
Айзенберга, — которые разработали курсы повышения квалификации с использованием
этих видеоматериалов и преподавали по ним в различных университетах и корпорациях
по всему миру.
Множество работников образования проделали значительную работу по переводу
первого издания. Мишель Бриан, Пьер Шамар и Андре Пик сделали французское издание, Сюзанна Дэниелс-Хэрольд выполнила немецкий перевод, а Фумио Мотоёси —
японский. Мы не знаем авторов китайского издания, однако считаем для себя честью
быть выбранными в качестве объекта «неавторизованного» перевода.
Трудно перечислить всех людей, внесших технический вклад в разработку систем
программирования на языке Scheme, которые мы используем в учебных целях. Кроме Гая Стила, в список важнейших волшебников входят Крис Хансон, Джо Боубир,
Джим Миллер, Гильермо Росас и Стефен Адамс. Кроме них, существенное время и
силы вложили Ричард Столлман, Алан Боуден, Кент Питман, Джон Тафт, Нил Мэйл,
Джон Лэмпинг, Гуин Оснос, Трейси Ларраби, Джордж Карретт, Сома Чаудхури, Билл
Киаркиаро, Стивен Кирш, Лей Клотц, Уэйн Носс, Тодд Кэсс, Патрик О’Доннелл, Кевин Теобальд, Дэниел Вайзе, Кеннет Синклер, Энтони Кортеманш, Генри М. Ву, Эндрю
Берлин и Рут Шью.
Помимо авторов реализации MIT, мы хотели бы поблагодарить множество людей,
работавших над стандартом Scheme IEEE, в том числе Уильяма Клингера и Джонатана Риса, которые редактировали R4 RS, а также Криса Хэйнса, Дэвида Бартли, Криса
Хансона и Джима Миллера, которые подготовили стандарт IEEE.
Долгое время Дэн Фридман был лидером сообщества языка Scheme. Работа сообщества в более широком плане переходит границы вопросов разработки языка и включает
значительные инновации в образовании, такие как курс для старшей школы, основанный на EdScheme компании Schemer’s Inc. и замечательные книги Майка Айзенберга,
Брайана Харви и Мэтью Райта.
Мы ценим труд тех, кто принял участие в превращении этой работы в настоящую
книгу, особенно Терри Элинга, Ларри Коэна и Пола Бетджа из издательства MIT Press.
Элла Мэйзел нашла замечательный рисунок для обложки. Что касается второго издания,
то мы особенно благодарны Бернарду и Элле Мэйзел за помощь с оформлением книги, а
также Дэвиду Джонсу, великому волшебнику TEXа. Мы также в долгу перед читателя-

20

Благодарности

ми, сделавшими проницательные замечания по новому проекту: Джекобу Кацнельсону,
Харди Мейеру, Джиму Миллеру и в особенности Брайану Харви, который был для этой
книги тем же, кем Джули была для его книги Просто Scheme.
Наконец, мы хотели бы выразить признательность организациям, которые поддерживали нашу работу в течение этих лет. Мы благодарны компании Хьюлетт-Паккард за
поддержку, которая стала возможной благодаря Айре Гольдстейну и Джоэлю Бирнбауму, а также агентству DARPA за поддержку, которая стала возможной благодаря Бобу
Кану.∗

∗ Со своей стороны хотелось бы поблагодарить Константина Добкина, Андрея Комеча, Сергея Коропа, Алексея Овчинникова, Алекса Отта, Вадима Радионова, Марию Рубинштейн и особенно Бориса Смилгу. — прим.
перев.

Благодарности
pgh

21

ГЛАВА 1
ПОСТРОЕНИЕ

АБСТРАКЦИЙ С ПОМОЩЬЮ
ПРОЦЕДУР
Действия, в которых ум проявляет свои
способности в отношении своих простых
идей, суть главным образом следующие
три: 1) Соединение нескольких простых
идей в одну сложную; так образовались
все сложные идеи, 2) Сведение вместе
двух идей, все равно, простых или
сложных, и сопоставление их друг с
другом так, чтобы обозревать их сразу,
но не соединять в одну; так ум
приобретает все свои идеи отношений,
3) Обособление идей от всех других
идей, сопутствующих им в реальной
действительности; это действие
называется «абстрагированием», и при
его помощи образованы все общие идеи в
уме.
Джон Локк.
«Опыт о человеческом разуме» (1690)
(Перевод А.Н. Савина)

Мы собираемся изучать понятие вычислительного процесса (computational process).
Вычислительные процессы — это абстрактные существа, которые живут в компьютерах.
Развиваясь, процессы манипулируют абстракциями другого типа, которые называются
данными (data). Эволюция процесса направляется набором правил, называемым программой (program). В сущности, мы заколдовываем духов компьютера с помощью своих
чар.
Вычислительные процессы и вправду вполне соответствуют представлениям колдуна о ду́хах. Их нельзя увидеть или потрогать. Они вообще сделаны не из вещества. В
то же время они совершенно реальны. Они могут выполнять умственную работу, могут
отвечать на вопросы. Они способны воздействовать на внешний мир, оплачивая счета в
банке или управляя рукой робота на заводе. Программы, которыми мы пользуемся для
заклинания процессов, похожи на чары колдуна. Они тщательно составляются из символических выражений на сложных и немногим известных языках программирования
(programming languages), описывающих задачи, которые мы хотим поручить процессам.
На исправно работающем компьютере вычислительный процесс выполняет программы точно и безошибочно. Таким образом, подобно ученику чародея, программисты-но-

23
вички должны научиться понимать и предсказывать последствия своих заклинаний. Даже мелкие ошибки (их обычно называют блохами (bugs) или глюками (glitches)), могут
привести к сложным и непредсказуемым последствиям.
К счастью, обучение программированию не так опасно, как обучение колдовству,
поскольку духи, с которыми мы имеем дело, надежно связаны. В то же время программирование в реальном мире требует осторожности, профессионализма и мудрости.
Например, мелкая ошибка в программе автоматизированного проектирования может привести к катастрофе самолета, прорыву плотины или самоуничтожению промышленного
робота.
Специалисты по программному обеспечению умеют организовывать программы так,
чтобы быть потом обоснованно уверенными: получившиеся процессы будут выполнять те
задачи, для которых они предназначены. Они могут изобразить поведение системы заранее. Они знают, как построить программу так, чтобы непредвиденные проблемы не привели к катастрофическим последствиям, а когда эти проблемы возникают, программисты
умеютотлаживать (debug) свои программы. Хорошо спроектированные вычислительные системы, подобно хорошо спроектированным автомобилям или ядерным реакторам,
построены модульно, так что их части могут создаваться, заменяться и отлаживаться по
отдельности.
Программирование на Лиспе
Для описания процессов нам нужен подходящий язык, и с этой целью мы используем
язык программирования Лисп. Точно так же, как обычные наши мысли чаще всего выражаются на естественном языке (например, английском, французском или японском), а
описания количественных явлений выражаются языком математики, наши процедурные
мысли будут выражаться на Лиспе. Лисп был изобретен в конце 1950-х как формализм
для рассуждений об определенном типе логических выражений, называемых уравнения
рекурсии (recursion equations), как о модели вычислений. Язык был придуман Джоном Маккарти и основывается на его статье «Рекурсивные функции над символьными
выражениями и их вычисление с помощью машины» (McCarthy 1960).
Несмотря на то, что Лисп возник как математический формализм, это практический
язык программирования. Интерпретатор (interpreter) Лиспа представляет собой машину, которая выполняет процессы, описанные на языке Лисп. Первый интерпретатор
Лиспа написал сам Маккарти с помощью коллег и студентов из Группы по Искусственному Интеллекту Исследовательской лаборатории по Электронике MIT и Вычислительного центра MIT1 . Лисп, чье название происходит от сокращения английских слов LISt
Processing (обработка списков), был создан с целью обеспечить возможность символьной
обработки для решения таких программистских задач, как символьное дифференцирование и интегрирование алгебраических выражений. С этой целью он содержал новые
объекты данных, известные под названием атомов и списков, что резко отличало его от
других языков того времени.
Лисп не был результатом срежиссированного проекта. Он развивался неформально,
экспериментальным путем, с учетом запросов пользователей и прагматических соображений реализации. Неформальная эволюция Лиспа продолжалась долгие годы, и сооб1 Руководство программиста по Лиспу 1 появилось в 1960 году, а Руководство программиста по Лиспу 1.5 (McCarthy 1965) в 1965 году. Ранняя история Лиспа описана в McCarthy 1978.

24

Глава 1. Построение абстракций с помощью процедур

щество пользователей Лиспа традиционно отвергало попытки провозгласить какое-либо
«официальное» описание языка. Вместе с гибкостью и изяществом первоначального замысла такая эволюция позволила Лиспу, который сейчас по возрасту второй из широко
используемых языков (старше только Фортран), непрерывно адаптироваться и вбирать в
себя наиболее современные идеи о проектировании программ. Таким образом, сегодня
Лисп представляет собой семью диалектов, которые, хотя и разделяют большую часть
изначальных свойств, могут существенным образом друг от друга отличаться. Тот диалект, которым мы пользуемся в этой книге, называется Scheme (Схема)2 .
Из-за своего экспериментального характера и внимания к символьной обработке первое время Лисп был весьма неэффективен при решении вычислительных задач, по крайней мере по сравнению с Фортраном. Однако за прошедшие годы были разработаны
компиляторы Лиспа, которые переводят программы в машинный код, способный производить численные вычисления с разумной эффективностью. А для специализированных
приложений Лисп удавалось использовать весьма эффективно3. Хотя Лисп и не преодолел пока свою старую репутацию безнадежно медленного языка, в наше время он
используется во многих приложениях, где эффективность не является главной заботой.
Например, Лисп стал любимым языком для оболочек операционных систем, а также в качестве языка расширения для редакторов и систем автоматизированного проектирования.
Но коль скоро Лисп не похож на типичные языки, почему же мы тогда используем его
как основу для нашего разговора о программировании? Потому что этот язык обладает
уникальными свойствами, которые делают его замечательным средством для изучения
важнейших конструкций программирования и структур данных, а также для соотнесения
их с деталями языка, которые их поддерживают. Самое существенное из этих свойств —
то, что лисповские описания процессов, называемые процедурами (procedures), сами по
себе могут представляться и обрабатываться как данные Лиспа. Важность этого в том,
что существуют мощные методы проектирования программ, которые опираются на возможность сгладить традиционное различение «пассивных» данных и «активных» процессов. Как мы обнаружим, способность Лиспа рассматривать процедуры в качестве данных
делает его одним из самых удобных языков для исследования этих методов. Способность
2 Большинство крупных Лисп-программ 1970х, были написаны на одном из двух диалектов: MacLisp (Moon
1978; Pitman 1983), разработанный в рамках проекта MAC в MIT, и InterLisp (Teitelman 1974), разработанный в компании «Болт, Беранек и Ньюман» и в Исследовательском центре компании Xerox в Пало Альто.
Диалект Portable Standard Lisp (Переносимый Стандартный Лисп, Hearn 1969; Griss 1981) был спроектирован
так, чтобы его легко было переносить на разные машины. MacLisp породил несколько поддиалектов, например
Franz Lisp, разработанный в Калифорнийском университете в Беркли, и Zetalisp (Moon 1981), который основывался на специализированном процессоре, спроектированном в лаборатории Искусственного Интеллекта в
MIT для наиболее эффективного выполнения программ на Лиспе. Диалект Лиспа, используемый в этой книге,
называется Scheme (Steele 1975). Он был изобретен в 1975 году Гаем Льюисом Стилом мл. и Джеральдом
Джеем Сассманом в лаборатории Искусственного Интеллекта MIT, а затем заново реализован для использования в учебных целях в MIT. Scheme стала стандартом IEEE в 1990 году (IEEE 1900). Диалект Common Lisp
(Steele 1982; Steele 1990) был специально разработан Лисп-сообществом так, чтобы сочетать свойства более
ранних диалектов Лиспа и стать промышленным стандартом Лиспа. Common Lisp стал стандартом ANSI в
1994 году (ANSI 1994).
3 Одним из таких приложений был пионерский эксперимент, имевший научное значение — интегрирование
движения Солнечной системы, которое превосходило по точности предыдущие результаты примерно на два
порядка и продемонстрировало, что динамика Солнечной системы хаотична. Это вычисление стало возможным
благодаря новым алгоритмам интегрирования, специализированному компилятору и специализированному компьютеру; причем все они были реализованы с помощью программных средств, написанных на Лиспе (Abelson
et al. 1992; Sussman and Wisdom 1992).

1.1. Элементы программирования

25

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

1.1. Элементы программирования
Мощный язык программирования — это нечто большее. чем просто средство, с помощью которого можно учить компьютер решать задачи. Язык также служит средой, в
которой мы организуем свое мышление о процессах. Таким образом, когда мы описываем
язык, мы должны уделять особое внимание тем средствам, которые в нем имеются для
того, чтобы комбинировать простые понятия и получать из них сложные. Всякий язык
программирования обладает тремя предназначенными для этого механизмами:
элементарные выражения, представляющие минимальные сущности, с которыми язык
имеет дело;
средства комбинирования, с помощью которых из простых объектов составляются
сложные;
средства абстракции, с помощью которых сложные объекты можно называть и обращаться с ними как с единым целым.
В программировании мы имеем дело с двумя типами объектов: процедурами и данными. (Впоследствии мы обнаружим, что на самом деле большой разницы между ними нет.)
Говоря неформально, данные — это «материал», который мы хотим обрабатывать, а процедуры — это описания правил обработки данных. Таким образом, от любого мощного
языка программирования требуется способность описывать простые данные и элементарные процедуры, а также наличие средств комбинирования и абстракции процедур и
данных.
В этой главе мы будем работать только с простыми численными данными, так что мы
сможем сконцентрировать внимание на правилах построения процедур4 . В последующих
главах мы увидим, что те же самые правила позволяют нам строить процедуры для
работы со сложными данными.
4 Называть числа «простыми данными» — это бесстыдный блеф. На самом деле работа с числами является
одной из самых сложных и запутанных сторон любого языка программирования. Вот некоторые из возникающих при этом вопросов: Некоторые компьютеры отличают целые числа (integers), вроде 2, от вещественных
(real numbers), вроде 2.71. Отличается ли вещественное число 2.00 от целого 2? Используются ли одни и те
же арифметические операции для целых и для вещественных чисел? Что получится, если 6 поделить на 2:
3 или 3.0? Насколько большие числа мы можем представить? Сколько десятичных цифр после запятой мы
можем хранить? Совпадает ли диапазон целых чисел с диапазоном вещественных? И помимо этих вопросов,
разумеется, существует множество проблем, связанных с ошибками округления — целая наука численного
анализа. Поскольку в этой книге мы говорим о проектировании больших программ, а не о численных методах,
все эти проблемы мы будем игнорировать. Численные примеры в этой главе будут демонстрировать такое поведение при округлении, какое можно наблюдать, если использовать арифметические операции, сохраняющие
при работе с вещественными числами ограниченное число десятичных цифр после запятой.

26

Глава 1. Построение абстракций с помощью процедур

1.1.1. Выражения
Самый простой способ начать обучение программированию — рассмотреть несколько
типичных примеров работы с интерпретатором диалекта Лиспа Scheme. Представьте, что
Вы сидите за терминалом компьютера. Вы печатаете выражение (expression), а интерпретатор отвечает, выводя результат вычисления (evaluation) этого выражения.
Один из типов элементарных выражений, которые Вы можете вводить — это числа.
(Говоря точнее, выражение, которое Вы печатаете, состоит из цифр, представляющих
число по основанию 10.) Если Вы дадите Лиспу число
486

интерпретатор ответит Вам, напечатав5
486

Выражения, представляющие числа, могут сочетаться с выражением, представляющим элементарную процедуру (скажем, + или *), так что получается составное выражение, представляющее собой применение процедуры к этим числам. Например:
(+ 137 349)
486
(- 1000 334)
666
(* 5 99)
495
(/ 10 5)
2
(+ 2.7 10)
12.7

Выражения такого рода, образуемые путем заключения списка выражений в скобки с целью обозначить применение функции к аргументам, называются комбинациями (combinations). Самый левый элемент в списке называетсяоператором (operator), а
остальные элементы — операндами (operands). Значение комбинации вычисляется путем применения процедуры, задаваемой оператором, каргументам (arguments), которые
являются значениями операндов.
Соглашение, по которому оператор ставится слева от операндов, известно как префиксная нотация (prefix notation), и поначалу оно может сбивать с толку, поскольку
существенно отличается от общепринятой математической записи. Однако у префиксной
нотации есть несколько преимуществ. Одно из них состоит в том, что префиксная запись
может распространяться на процедуры с произвольным количеством аргументов, как в
следующих примерах:
5 Здесь и далее, когда нам нужно будет подчеркнуть разницу между вводом, который набирает на терминале пользователь, и выводом, который производит компьютер, мы будем изображать последний наклонным
шрифтом.

1.1. Элементы программирования

27

(+ 21 35 12 7)
75
(* 25 4 12)
1200

Не возникает никакой неоднозначности, поскольку оператор всегда находится слева, а
вся комбинация ограничена скобками.
Второе преимущество префиксной нотации состоит в том, что она естественным образом расширяется, позволяя комбинациям вкладываться (nest) друг в друга, то есть
допускает комбинации, элементы которых сами являются комбинациями:
(+ (* 3 5) (- 10 6))
19

Не существует (в принципе) никакого предела для глубины такого вложения и общей
сложности выражений, которые может вычислять интерпретатор Лиспа. Это мы, люди,
путаемся даже в довольно простых выражениях, например
(+ (* 3 (+ (* 2 4) (+ 3 5))) (+ (- 10 7) 6))

а интерпретатор с готовностью вычисляет его и дает ответ 57. Мы можем облегчить себе
задачу, записывая такие выражения в форме
(+ (* 3
(+ (* 2 4)
(+ 3 5)))
(+ (- 10 7)
6))

Эти правила форматирования называются красивая печать (pretty printing). Согласно
им, всякая длинная комбинация записывается так, чтобы ее операнды выравнивались
вертикально. Получающиеся отступы ясно показывают структуру выражения6 .
Даже работая со сложными выражениями, интерпретатор всегда ведет себя одинаковым образом: он считывает выражение с терминала, вычисляет его и печатает результат.
Этот способ работы иногда называют циклом чтение-вычисление-печать (read-eval-print
loop). Обратите особое внимание на то, что не нужно специально просить интерпретатор
напечатать значение выражения7 .

1.1.2. Имена и окружение
Одна из важнейших характеристик языка программирования — какие в нем существуют средства использования имен для указания на вычислительные объекты. Мы
6 Как

правило, Лисп-системы содержат средства, которые помогают пользователям форматировать выражения. Особенно удобны две возможности: сдвигать курсор на правильную позицию для красивой печати каждый
раз, когда начинается новая строка и подсвечивать нужную левую скобку каждый раз, когда печатается правая.
7 Лисп следует соглашению, что у всякого выражения есть значение. Это соглашение, вместе со старой
репутацией Лиспа как неэффективного языка, послужило источником остроумного замечания Алана Перлиса
(парафразы из Оскара Уайльда), что «Программисты на Лиспе знают значение всего на свете, но ничему не
знают цену».

Глава 1. Построение абстракций с помощью процедур

28

говорим, что имя обозначает переменную (variable), чьим значением (value) является
объект.
В диалекте Лиспа Scheme мы даем вещам имена с помощью слова define. Предложение
(define size 2)

заставляет интерпретатор связать значение 2 с именем size8 . После того, как имя size
связано со значением 2, мы можем указывать на значение 2 с помощью имени:
size
2
(* 5 size)
10

Вот еще примеры использования define:
(define pi 3.14159)
(define radius 10)
(* pi (* radius radius))
314.159
(define circumference (* 2 pi radius))
circumference
62.8318

Слово define служит в нашем языке простейшим средством абстракции, поскольку оно позволяет нам использовать простые имена для обозначения результатов сложных
операций, как, например, вычисленная только что длина окружности — circumference.
Вообще говоря, вычислительные объекты могут быть весьма сложными структурами, и
было бы очень неудобно, если бы нам приходилось вспоминать и повторять все их детали
каждый раз, когда нам захочется их использовать. На самом деле сложные программы
конструируются методом построения шаг за шагом вычислительных объектов возрастающей сложности. Интерпретатор делает такое пошаговое построение программы особенно
удобным, поскольку связи между именами и объектами могут создаваться последовательно по мере взаимодействия программиста с компьютером. Это свойство интерпретаторов
облегчает пошаговое написание и тестирование программ, и во многом благодаря именно
ему получается так, что программы на Лиспе обычно состоят из большого количества
относительно простых процедур.
Ясно, что раз интерпретатор способен ассоциировать значения с символами и затем вспоминать их, то он должен иметь некоторого рода память, сохраняющую пары
имя-объект. Эта память называется окружением (environment) (а точнее, глобальным
8 Мы не печатаем в этой книге ответы интерпретатора при вычислении определений, поскольку они зависят
от конкретной реализации языка.

1.1. Элементы программирования

29

окружением (global environment), поскольку позже мы увидим, что вычисление может
иметь дело с несколькими окружениями)9 .

1.1.3. Вычисление комбинаций
Одна из наших целей в этой главе — выделить элементы процедурного мышления.
Рассуждая в этом русле, примем во внимание, что интерпретатор, вычисляя значение
комбинации, тоже следует процедуре:
• Чтобы вычислить комбинацию, требуется:
– Вычислить все подвыражения комбинации.
– Применить процедуру, которая является значением самого левого подвыражения (оператора) к аргументам — значениям остальных подвыражений (операндов).
Даже в этом простом правиле видны несколько важных свойств процессов в целом.
Прежде всего, заметим, что на первом шаге для того, чтобы провести процесс вычисления для комбинации, нужно сначала проделать процесс вычисления для каждого элемента комбинации. Таким образом, правило вычисления рекурсивно (recursive) по своей
природе; это означает, что в качестве одного из своих шагов оно включает применение
того же самого правила10 .
Заметьте, какую краткость понятие рекурсии придает описанию того, что в случае
комбинации с глубоким вложением выглядело бы как достаточно сложный процесс. Например, чтобы вычислить
(* (+ 2 (* 4 6))
(+ 3 5 7))

требуется применить правило вычисления к четырем различным комбинациям. Картину
этого процесса можно получить, нарисовав комбинацию в виде дерева, как показано на
рис. 1.1. Каждая комбинация представляется в видевершины, а ее оператор и операнды — в виде ветвей, исходящих из этой вершины. Концевые вершины (то есть те, из
которых не исходит ни одной ветви) представляют операторы или числа. Рассматривая
вычисление как дерево, мы можем представить себе, что значения операндов распространяются от концевых вершин вверх и затем комбинируются на все более высоких
уровнях. Впоследствии мы увидим, что рекурсия — это вообще очень мощный метод
обработки иерархических, древовидных объектов. На самом деле форма правила вычисления «распространить значения наверх» является примером общего типа процессов,
известного как накопление по дереву (tree accumulation).
9

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

Глава 1. Построение абстракций с помощью процедур

30

390

*
15

26

7

+

+

3

24

2

*

5

6
4

Рис. 1.1. Вычисление, представленное в виде дерева.

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

• значением встроенных операторов являются последовательности машинных команд, которые выполняют соответствующие операции; и
• значением остальных имен являются те объекты, с которыми эти имена связаны в
окружении.
Мы можем рассматривать второе правило как частный случай третьего, постановив,
что символы вроде + и * тоже включены в глобальное окружение и связаны с последовательностями машинных команд, которые и есть их «значения». Главное здесь — это
роль окружения при определении значения символов в выражениях. В таком диалоговом
языке, как Лисп, не имеет смысла говорить о значении выражения, скажем, (+ x 1),
не указывая никакой информации об окружении, которое дало бы значение символу x
(и даже символу +). Как мы увидим в главе 3, общее понятие окружения, предоставляющего контекст, в котором происходит вычисление, будет играть важную роль в нашем
понимании того, как выполняются программы.
Заметим, что рассмотренное нами правило вычисления не обрабатывает определений.
Например, вычисление (define x 3) не означает применение define к двум аргументам, один из которых значение символа x, а другой равен 3, поскольку смысл define
как раз и состоит в том, чтобы связать x со значением. (Таким образом, (define
x 3) — не комбинация.)

1.1. Элементы программирования

31

Такие исключения из вышеописанного правила вычисления называются особыми
формами (special forms). Define — пока что единственный встретившийся нам пример
особой формы, но очень скоро мы познакомимся и с другими. У каждой особой формы свое собственное правило вычисления. Разные виды выражений (вместе со своими
правилами вычисления) составляют синтаксис языка программирования. По сравнению
с большинством языков программирования, у Лиспа очень простой синтаксис; а именно, правило вычисления для выражений может быть описано как очень простое общее
правило плюс специальные правила для небольшого числа особых форм11 .

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

• Определения, которые связывают имена со значениями, дают ограниченные возможности абстракции.
Теперь мы узнаем об определениях процедур (procedure definitions) — значительно
более мощном методе абстракции, с помощью которого составной операции можно дать
имя и затем ссылаться на нее как на единое целое.
Для начала рассмотрим, как выразить понятие «возведения в квадрат». Можно сказать так: «Чтобы возвести что-нибудь в квадрат, нужно умножить его само на себя».
Вот как это выражается в нашем языке:
(define (square x) (* x x))

Это можно понимать так:
(define

Чтобы

(square

возвести в квадрат

x)

что-л.

*

умножь

x

это

x))

само на себя

Здесь мы имеем составную процедуру (compound procedure), которой мы дали имя
square. Эта процедура представляет операцию умножения чего-либо само на себя. Та
вещь, которую нужно подвергнуть умножению, получает здесь имя x, которое играет ту
11 Особые синтаксические формы, которые представляют собой просто удобное альтернативное поверхностное
представление для того, что можно выразить более унифицированным способом, иногда называют синтаксическим сахаром (syntactic sugar), используя выражение Питера Ландина. По сравнению с пользователями
других языков, программистов на Лиспе, как правило, мало волнует синтаксический сахар. (Для контраста
возьмите руководство по Паскалю и посмотрите, сколько места там уделяется описанию синтаксиса). Такое
презрение к синтаксису отчасти происходит от гибкости Лиспа, позволяющего легко изменять поверхностный
синтаксис, а отчасти из наблюдения, что многие «удобные» синтаксические конструкции, которые делают язык
менее последовательным, приносят в конце концов больше вреда, чем пользы, когда программы становятся
большими и сложными. По словам Алана Перлиса, «Синтаксический сахар вызывает рак точки с запятой».

32

Глава 1. Построение абстракций с помощью процедур

же роль, что в естественных языках играет местоимение. Вычисление этого определения
создает составную процедуру и связывает ее с именем square12 .
Общая форма определения процедуры такова:
(define (hимяi hформальные-параметрыi) hтелоi)

hИмяi — это тот символ, с которым нужно связать в окружении определение процедуры13 . hФормальные-параметрыi — это имена, которые в теле процедуры используются
для отсылки к соответствующим аргументам процедуры. hТелоi — это выражение, которое вычислит результат применения процедуры, когда формальные параметры будут заменены аргументами, к которым процедура будет применяться14 . hИмяi и hформальныепараметрыi заключены в скобки, как это было бы при вызове определяемой процедуры.
Теперь, когда процедура square определена, мы можем ее использовать:
(square 21)
441
(square (+ 2 5))
49
(square (square 3))
81

Кроме того, мы можем использовать square при определении других процедур. Например, x2 + y 2 можно записать как
(+ (square x) (square y)))

Легко можно определить процедуру sum-of-squares, которая, получая в качестве
аргументов два числа, дает в результате сумму их квадратов:
(define (sum-of-squares x y)
(+ (square x) (square y)))
(sum-of-squares 3 4)
25

Теперь и sum-of-squares мы можем использовать как строительный блок при дальнейшем определении процедур:
12 Заметьте, что здесь присутствуют две различные операции: мы создаем процедуру, и мы даем ей имя
square. Возможно, и на самом деле даже важно, разделить эти два понятия: создавать процедуры, никак их
не называя, и давать имена процедурам, уже созданным заранее. Мы увидим, как это делается, в разделе 1.3.2.
13 На всем протяжении этой книги мы будем описывать обобщенныйсинтаксис выражений, используя курсив
в угловых скобках — напр. hимяi, чтобы обозначить «дырки» в выражении, которые нужно заполнить, когда
это выражение используется в языке.
14 В более общем случае тело процедуры может быть последовательностью выражений. В этом случае интерпретатор вычисляет по очереди все выражения в этой последовательности и возвращает в качестве значения
применения процедуры значение последнего выражения.

1.1. Элементы программирования

33

(define (f a)
(sum-of-squares (+ a 1) (* a 2)))
(f 5)
136

Составные процедуры используются точно так же, как элементарные. В самом деле,
глядя на приведенное выше определение sum-of-squares, невозможно выяснить, была
ли square встроена в интерпретатор, подобно + и *, или ее определили как составную
процедуру.

1.1.5. Подстановочная модель применения процедуры
Вычисляя комбинацию, оператор которой называет составную процедуру, интерпретатор осуществляет, вообще говоря, тот же процесс, что и для комбинаций, операторы
которых называют элементарные процедуры — процесс, описанный в разделе 1.1.3. А
именно, интерпретатор вычисляет элементы комбинации и применяет процедуру (значение оператора комбинации) к аргументам (значениям операндов комбинации).
Мы можем предположить, что механизм применения элементарных процедур к аргументам встроен в интерпретатор. Для составных процедур процесс протекает так:
• Чтобы применить составную процедуру к аргументам, требуется вычислить тело
процедуры, заменив каждый формальный параметр соответствующим аргументом.
Чтобы проиллюстрировать этот процесс, вычислим комбинацию
(f 5)

где f — процедура, определенная в разделе 1.1.4. Начинаем мы с того, что восстанавливаем тело f:
(sum-of-squares (+ a 1) (* a 2))

Затем мы заменяем формальный параметр a на аргумент 5:
(sum-of-squares (+ 5 1) (* 5 2))

Таким образом, задача сводится к вычислению комбинации с двумя операндами и оператором sum-of-squares. Вычисление этой комбинации включает три подзадачи. Нам
нужно вычислить оператор, чтобы получить процедуру, которую требуется применить, а
также операнды, чтобы получить аргументы. При этом (+ 5 1) дает 6, а (* 5 2) дает
10, так что нам требуется применить процедуру sum-of-squares к 6 и 10. Эти значения подставляются на место формальных параметров x и y в теле sum-of-squares,
приводя выражение к
(+ (square 6) (square 10))

Когда мы используем определение square, это приводится к
(+ (* 6 6) (* 10 10))

34

Глава 1. Построение абстракций с помощью процедур

что при умножении сводится к
(+ 36 100)

и, наконец, к
136

Только что описанный нами процесс называется подстановочной моделью (substitution
model) применения процедуры. Ее можно использовать как модель, которая определяет
«смысл» понятия применения процедуры, пока рассматриваются процедуры из этой главы. Имеются, однако, две детали, которые необходимо подчеркнуть:
• Цель подстановочной модели — помочь нам представить, как применяются процедуры, а не дать описание того, как на самом деле работает интерпретатор. Как правило, интерпретаторы вычисляют применения процедур к аргументам без манипуляций с
текстом процедуры, которые выражаются в подстановке значений для формальных параметров. На практике «подстановка» реализуется с помощью локальных окружений для
формальных параметров. Более подробно мы обсудим это в главах 3 и 4, где мы детально
исследуем реализацию интерпретатора.
• На протяжении этой книги мы представим последовательность усложняющихся
моделей того, как работает интерпретатор, завершающуюся полным воплощением интерпретатора и компилятора в главе 5. Подстановочная модель — только первая из них,
способ начать формально мыслить о моделях вычисления. Вообще, моделируя различные
явления в науке и технике, мы начинаем с упрощенных, неполных моделей. Подстановочная модель в этом смысле не исключение. В частности, когда в главе 3 мы обратимся к
использованию процедур с «изменяемыми данными», то мы увидим, что подстановочная
модель этого не выдерживает и ее нужно заменить более сложной моделью применения
процедур15 .
Аппликативный и нормальный порядки вычисления
В соответствии с описанием из раздела 1.1.3, интерпретатор сначала вычисляет оператор и операнды, а затем применяет получившуюся процедуру к получившимся аргументам. Но это не единственный способ осуществлять вычисления. Другая модель
вычисления не вычисляет аргументы, пока не понадобится их значение. Вместо этого
она подставляет на место параметров выражения-операнды, пока не получит выражение, в котором присутствуют только элементарные операторы, и лишь затем вычисляет
его. Если бы мы использовали этот метод, вычисление
(f 5)

прошло бы последовательность подстановок
15 Несмотря на простоту подстановочной модели, дать строгое математическое определение процессу подстановки оказывается удивительно сложно. Проблема возникает из-за возможности смешения имен, которые
используются как формальные параметры процедуры, с именами (возможно, с ними совпадающими), которые
используются в выражениях, к которым процедура может применяться. Имеется долгая история неверных
определений подстановки (substitution) в литературе по логике и языкам программирования. Подробное обсуждение подстановки можно найти в Stoy 1977.

1.1. Элементы программирования

35

(sum-of-squares (+ 5 1) (* 5 2))
(+

(square (+ 5 1))

(square (* 5 2))

)

(+

(* (+ 5 1) (+ 5 1))

(* (* 5 2) (* 5 2)))

за которыми последуют редукции
(+

(* 6 6)

(* 10 10))

(+

36

100)
136

Это дает тот же результат, что и предыдущая модель вычислений, но процесс его получения отличается. В частности, вычисление (+ 5 1) и (* 5 2) выполняется здесь по
два раза, в соответствии с редукцией выражения
(* x x)

где x заменяется, соответственно, на (+ 5 1) и (* 5 2).
Альтернативный метод «полная подстановка, затем редукция» известен под названием нормальный порядок вычислений (normal-order evaluation), в противоположность
методу «вычисление аргументов, затем применение процедуры», которое называется аппликативным порядком вычислений (applicative-order evaluation). Можно показать, что
для процедур, которые правильно моделируются с помощью подстановки (включая все
процедуры из первых двух глав этой книги) и возвращают законные значения, нормальный и аппликативный порядки вычисления дают одно и то же значение. (См. упражнение 1.5, где приводится пример «незаконного» выражения, для которого нормальный и
аппликативный порядки вычисления дают разные результаты.)
В Лиспе используется аппликативный порядок вычислений, отчасти из-за дополнительной эффективности, которую дает возможность не вычислять многократно выражения вроде приведенных выше (+ 5 1) и (* 5 2), а отчасти, что важнее, потому что с
нормальным порядком вычислений становится очень сложно обращаться, как только мы
покидаем область процедур, которые можно смоделировать с помощью подстановки. С
другой стороны, нормальный порядок вычислений может быть весьма ценным инструментом, и некоторые его применения мы рассмотрим в главах 3 и 416 .

1.1.6. Условные выражения и предикаты
Выразительная сила того класса процедур, которые мы уже научились определять,
очень ограничена, поскольку пока что у нас нет способа производить проверки и выполнять различные операции в зависимости от результата проверки. Например, мы не
способны определить процедуру, вычисляющую модуль числа, проверяя, положительное
16 В главе 3 мы описываем обработку потоков (stream processing), которая представляет собой способ обработки структур данных, кажущихся «бесконечными», с помощью ограниченной формы нормального порядка
вычислений. В разделе 4.2 мы модифицируем интерпретатор Scheme так, что получается вариант языка с
нормальным порядком вычислений.

Глава 1. Построение абстракций с помощью процедур

36

ли это число, отрицательное или ноль, и предпринимая различные действия в соответствии с правилом

 x если x > 0
0 если x = 0
|x| =

−x если x < 0

Такая конструкция называется разбором случаев (case analysis). В Лиспе существует
особая форма для обозначения такого разбора случаев.Она называется cond (от английского слова conditional, «условный») и используется так:
(define (abs x)
(cond ((> x 0) x)
((= x 0) 0)
((< x 0) (- x))))

Общая форма условного выражения такова:
(cond (hp1 i he1 i)
(hp2 i he2 i)
.
.
.
(hpn i hen i))

Она состоит из символа cond, за которым следуют заключенные в скобки пары
выражений (hpi hei), называемых ветвями (clauses). В каждой из этих пар первое
выражение — предикат (predicate), то есть выражение, значение которого интерпретируется как истина или ложь17 .
Условные выражения вычисляются так: сначала вычисляется предикат hp1 i. Если его
значением является ложь, вычисляется hp2 i. Если значение hp2 i также ложь, вычисляется hp3 i. Этот процесс продолжается до тех пор, пока не найдется предикат, значением которого будет истина, и в этом случае интерпретатор возвращает значение соответствующего выражения-следствия (consequent expression) в качестве значения всего
условного выражения. Если ни один из hpi ни окажется истинным, значение условного
выражения не определено.
Словом предикат называют процедуры, которые возвращают истину или ложь, а
также выражения, которые имеют значением истину или ложь. Процедура вычисления
модуля использует элементарные предикаты 18 .
Они принимают в качестве аргументов по два числа и, проверив, меньше ли первое
из них второго, равно ему или больше, возвращают в зависимости от этого истину или
ложь.
Можно написать процедуру вычисления модуля и так:
17 «Интерпретируется

как истина или ложь» означает следующее: в языке Scheme есть два выделенных
значения, которые обозначаются константами #t и #f. Когда интерпретатор проверяет значение предиката,
он интерпретирует #f как ложь. Любое другое значение считается истиной. (Таким образом, наличие #t
логически не является необходимым, но иметь его удобно.) В этой книге мы будем использовать имена true
и false, которые связаны со значениями #t и #f, соответственно.
18 Еще она использует операцию «минус» -, которая, когда используется с одним операндом, как в выражении (- x), обозначает смену знака.

1.1. Элементы программирования

37

(define (abs x)
(cond ((< x 0) (- x))
(else x)))

что на русском языке можно было бы выразить следующим образом: «если x меньше
нуля, вернуть −x; иначе вернуть x». Else — специальный символ, который в заключительной ветви cond можно использовать на месте hpi. Это заставляет cond вернуть в
качестве значения значение соответствующего hei в случае, если все предыдущие ветви
были пропущены. На самом деле, здесь на месте hpi можно было бы использовать любое
выражение, которое всегда имеет значение истина.
Вот еще один способ написать процедуру вычисления модуля:
(define (abs x)
(if (< x 0)
(- x)
x))

Здесь употребляется особая форма if, ограниченный вид условного выражения. Его
можно использовать при разборе случаев, когда есть ровно два возможных исхода. Общая форма выражения if такова:
(if hпредикатi hследствиеi hальтернативаi)

Чтобы вычислить выражение if, интерпретатор сначала вычисляет его hпредикатi. Если
hпредикатi дает истинное значение, интерпретатор вычисляет hследствиеi и возвращает его значение. В противном случае он вычисляет hальтернативуi и возвращает ее
значение19.
В дополнение к элементарным предикатам вроде , существуют операции
логической композиции, которые позволяют нам конструировать составные предикаты.
Из них чаще всего используются такие:
• (and he1 i . . . hen i)
Интерпретатор вычисляет выражения hei по одному, слева направо. Если какое-нибудь
из hei дает ложное значение, значение всего выражения and — ложь, и остальные hei не
вычисляются. Если все hei дают истинные значения, значением выражения and является
значение последнего из них.
• (or he1 i . . . hen i)
Интерпретатор вычисляет выражения hei по одному, слева направо. Если какое-нибудь
из hei дает истинное значение, это значение возвращается как результат выражения
or, а остальные hei не вычисляются. Если все hei оказываются ложными, значением
выражения or является ложь.
• (not hei)

Значение выражения not — истина, если значение выражения hei ложно, и ложь в
противном случае.
19 Небольшая разница между if и cond состоит в том, что в cond каждое hei может быть последовательностью выражений. Если соответствующее hpi оказывается истинным, выражения из hei вычисляются по очереди,
и в качестве значения cond возвращается значение последнего из них. Напротив, в if как hследствиеi, так
и hальтернативаi обязаны состоять из одного выражения.

38

Глава 1. Построение абстракций с помощью процедур

Заметим, что and и or — особые формы, а не процедуры, поскольку не обязательно
вычисляются все подвыражения. Not — обычная процедура.
Как пример на использование этих конструкций, условие что число x находится в
диапазоне 5 < x < 10, можно выразить как
(and (> x 5) (< x 10))

Другой пример: мы можем определить предикат, который проверяет, что одно число
больше или равно другому, как
(define (>= x y)
(or (> x y) (= x y)))

или как
(define (>= x y)
(not (< x y)))

Упражнение 1.1.
Ниже приведена последовательность выражений. Какой результат напечатает интерпретатор в ответ на каждое из них? Предполагается, что выражения вводятся в том же порядке, в каком они
написаны.
10
(+ 5 3 4)
(- 9 1)
(/ 6 2)
(+ (* 2 4) (- 4 6))
(define a 3)
(define b (+ a 1))
(+ a b (* a b))
(= a b)
(if (and (> b a) (< b (* a b)))
b
a)
(cond ((= a 4) 6)
((= b 4) (+ 6 7 a))
(else 25))
(+ 2 (if (> b a) b a))

1.1. Элементы программирования

39

(* (cond ((> a b) a)
((< a b) b)
(else -1))
(+ a 1))

Упражнение 1.2.
Переведите следующее выражение в префиксную форму:
5 + 4 + (2 − (3 − (6 +

4
)))
5

3(6 − 2)(2 − 7)

Упражнение 1.3.
Определите процедуру, которая принимает в качестве аргументов три числа и возвращает сумму
квадратов двух бо́льших из них.
Упражнение 1.4.
Заметим, что наша модель вычислений разрешает существование комбинаций, операторы которых — составные выражения. С помощью этого наблюдения опишите, как работает следующая
процедура:
(define (a-plus-abs-b a b)
((if (> b 0) + -) a b))

Упражнение 1.5.
Бен Битобор придумал тест для проверки интерпретатора на то, с каким порядком вычислений он
работает, аппликативным или нормальным. Бен определяет такие две процедуры:
(define (p) (p))
(define (test x y)
(if (= x 0)
0
y))
Затем он вычисляет выражение
(test 0 (p))
Какое поведение увидит Бен, если интерпретатор использует аппликативный порядок вычислений?
Какое поведение он увидит, если интерпретатор использует нормальный порядок? Объясните Ваш
ответ. (Предполагается, что правило вычисления особой формы if одинаково независимо от того,
какой порядок вычислений используется. Сначала вычисляется выражение-предикат, и результат
определяет, нужно ли вычислять выражение-следствие или альтернативу.)

40

Глава 1. Построение абстракций с помощью процедур

1.1.7. Пример: вычисление квадратного корня методом Ньютона
Процедуры, как они описаны выше, очень похожи на обыкновенные математические
функции. Они устанавливают значение, которое определяется одним или более параметром. Но есть важное различие между математическими функциями и компьютерными
процедурами. Процедуры должны быть эффективными.
В качестве примера рассмотрим задачу вычисления квадратного корня. Мы можем
определить функцию «квадратный корень» так:

x = такое y, что y ≥ 0 и y 2 = x
Это описывает совершенно нормальную математическую функцию. С помощью такого
определения мы можем решать, является ли одно число квадратным корнем другого,
или выводить общие свойства квадратных корней. С другой стороны, это определение
не описывает процедуры. В самом деле, оно почти ничего не говорит о том, как найти
квадратный корень данного числа. Не поможет и попытка перевести это определение на
псевдо-Лисп:
(define (sqrt x)
(the y (and (>= y 0)
(= (square y) x))))

Это только уход от вопроса.
Противопоставление функций и процедур отражает общее различие между описанием свойств объектов и описанием того, как что-то делать, или, как иногда говорят,
различие между декларативным знанием и императивным знанием. В математике нас
обычно интересуют декларативные описания (что такое), а в информатике императивные описания (как)20 .
Как вычисляются квадратные корни? Наиболее часто применяется Ньютонов метод
последовательных приближений, который основан на том, что имея некоторое неточное
значение y для квадратного корня из числа x, мы можем с помощью простой манипуляции получить более точное значение (более близкое к настоящему квадратному корню),
если возьмем среднее между y и x/y 21 . Например, мы можем вычислить квадратный
20 Декларативные и императивные описания тесно связаны между собой, как и математика с информатикой.
Например, сказать, что ответ, получаемый программой, «верен», означает сделать об этой программе декларативное утверждение. Существует большое количество исследований, направленных на отыскание методов
доказательства того, что программа корректна, и большая часть сложности этого предмета исследования связана с переходом от императивных утверждений (из которых строятся программы) к декларативным (которые
можно использовать для рассуждений). Связана с этим и такая важная область современных исследований
по проектированию языков программирования, как исследование так называемыхязыков сверхвысокого уровня, в которых программирование на самом деле происходит в терминах декларативных утверждений. Идея
состоит в том, чтобы сделать интерпретаторы настолько умными, чтобы, получая от программиста знание типа
«что такое», они были бы способны самостоятельно породить знание типа «как». В общем случае это сделать
невозможно, но есть важные области, где удалось достичь прогресса. Мы вернемся к этой идее в главе 4.
21 На самом деле алгоритм нахождения квадратного корня представляет собой частный случай метода Ньютона, который является общим методом нахождения корней уравнений. Собственно алгоритм нахождения
квадратного корня был разработан Героном Александрийским в первом веке н.э. Мы увидим, как выразить
общий метод Ньютона в виде процедуры на Лиспе, в разделе 1.3.4.

1.1. Элементы программирования

41

корень из 2 следующим образом: предположим, что начальное приближение равно 1.
Приближение Частное x/y
1
1.5
1.4167
1.4142

Среднее

2
=2
1
2
= 1.3333
1.5
2
= 1.4118
1.4167

2+1
= 1.5
2
1.3333 + 1.5
= 1.4167
2
1.4167 + 1.4118
= 1.4142
2

...

...

Продолжая этот процесс, мы получаем все более точные приближения к квадратному
корню.
Теперь формализуем этот процесс в терминах процедур. Начнем с подкоренного числа и какого-то значения приближения. Если приближение достаточно хорошо подходит
для наших целей, то процесс закончен; если нет, мы должны повторить его с улучшенным значением приближения. Запишем эту базовую стратегию в виде процедуры:
(define (sqrt-iter guess x)
(if (good-enough? guess x)
guess
(sqrt-iter (improve guess x)
x)))

Значение приближения улучшается с помощью взятия среднего между ним и частным
подкоренного числа и старого значения приближения:
(define (improve guess x)
(average guess (/ x guess)))

где
(define (average x y)
(/ (+ x y) 2))

Нам нужно еще сказать, что такое для нас «достаточно хорошее» приближение. Следующий вариант сойдет для иллюстрации, но на самом деле это не очень хороший тест. (См.
упражнение 1.7.) Идея состоит в том, чтобы улучшать приближения до тех пор, пока
его квадрат не совпадет с подкоренным числом в пределах заранее заданного допуска
(здесь 0.001)22 :
(define (good-enough? guess x)
(< (abs (- (square guess) x)) 0.001))
22 Обычно мы будем давать предикатам имена, заканчивающиеся знаком вопроса, чтобы было проще запомнить, что это предикаты. Это не более чем стилистическое соглашение. С точки зрения интерпретатора,
вопросительный знак — обыкновенный символ.

42

Глава 1. Построение абстракций с помощью процедур

Наконец, нужно с чего-то начинать. Например, мы можем для начала предполагать, что
квадратный корень любого числа равен 123 :
(define (sqrt x)
(sqrt-iter 1.0 x))

Если мы введем эти определения в интерпретатор, мы сможем использовать sqrt как
любую другую процедуру:
(sqrt 9)
3.00009155413138
(sqrt (+ 100 37))
11.704699917758145
(sqrt (+ (sqrt 2) (sqrt 3)))
1.7739279023207892
(square (sqrt 1000))
1000.000369924366

Программа sqrt показывает также, что того простого процедурного языка, который мы описали до сих пор, достаточно, чтобы написать любую чисто вычислительную
программу, которую можно было бы написать, скажем, на Си или Паскале. Это может показаться удивительным, поскольку в наш язык мы не включили никаких итеративных (циклических) конструкций, указывающих компьютеру, что нужно производить
некое действие несколько раз. Sqrt-iter, с другой стороны, показывает, как можно
выразить итерацию, не имея никакого специального конструкта, кроме обыкновенной
способности вызвать процедуру24 .
Упражнение 1.6.
Лиза П. Хакер не понимает, почему if должна быть особой формой. «Почему нельзя просто
определить ее как обычную процедуру с помощью cond?» — спрашивает она. Лизина подруга Ева
Лу Атор утверждает, что, разумеется, можно, и определяет новую версию if:
(define (new-if predicate then-clause else-clause)
(cond (predicate then-clause)
(else else-clause)))
Ева показывает Лизе новую программу:
23 Обратите внимание, что мы записываем начальное приближение как 1.0, а не как 1.Во многих реализациях
Лиспа здесь не будет никакой разницы. Однако интерпретатор MIT Scheme отличает точные целые числа от
десятичных значений, и при делении двух целых получается не десятичная дробь, а рациональное число.
Например, поделив 10/6, получим 5/3, а поделив 10.0/6.0, получим 1.6666666666666667. (Мы увидим, как
реализовать арифметические операции над рациональными числами, в разделе 2.1.1.) Если в нашей программе
квадратного корня мы начнем с начального приближения 1, а x будет точным целым числом, все последующие
значения, получаемые при вычислении квадратного корня, будут не десятичными дробями, а рациональными
числами. Поскольку при смешанных операциях над десятичными дробями и рациональными числами всегда
получаются десятичные дроби, то начав со значения 1.0, все прочие мы получим в виде десятичных дробей.
24 Читателям, которых заботят вопросы эффективности, связанные с использованием вызовов процедур для
итерации, следует обратить внимание на замечания о «хвостовой рекурсии» в разделе 1.2.1.

1.1. Элементы программирования

43

(new-if (= 2 3) 0 5)
5
(new-if (= 1 1) 0 5)
0
Обрадованная Лиза переписывает через new-if программу вычисления квадратного корня:
(define (sqrt-iter guess x)
(new-if (good-enough? guess x)
guess
(sqrt-iter (improve guess x)
x)))
Что получится, когда Лиза попытается использовать эту процедуру для вычисления квадратных
корней? Объясните.
Упражнение 1.7.
Проверка good-enough?, которую мы использовали для вычисления квадратных корней, будет
довольно неэффективна для поиска квадратных корней от очень маленьких чисел. Кроме того, в
настоящих компьютерах арифметические операции почти всегда вычисляются с ограниченной точностью. Поэтому наш тест оказывается неадекватным и для очень больших чисел. Альтернативный
подход к реализации good-enough? состоит в том, чтобы следить, как от одной итерации к другой изменяется guess, и остановиться, когда изменение оказывается небольшой долей значения
приближения. Разработайте процедуру вычисления квадратного корня, которая использует такой
вариант проверки на завершение. Верно ли, что на больших и маленьких числах она работает
лучше?
Упражнение 1.8.
Метод Ньютона для кубических корней основан на том, что если y является приближением к
кубическому корню из x, то мы можем получить лучшее приближение по формуле
x/y 2 + 2y
3
С помощью этой формулы напишите процедуру вычисления кубического корня, подобную процедуре для квадратного корня. (В разделе 1.3.4 мы увидим, что можно реализовать общий метод
Ньютона как абстракцию этих процедур для квадратного и кубического корня.)

1.1.8. Процедуры как абстракции типа «черный ящик»
Sqrt — наш первый пример процесса, определенного множеством зависимых друг от
друга процедур. Заметим, что определение sqrt-iter рекурсивно (recursive); это означает, что процедура определяется в терминах самой себя. Идея, что можно определить
процедуру саму через себя, возможно, кажется Вам подозрительной; неясно, как такое
«циклическое» определение вообще может иметь смысл, не то что описывать хорошо
определенный процесс для исполнения компьютером. Более осторожно мы подойдем к
этому в разделе 1.2. Рассмотрим, однако, некоторые другие важные детали, которые
иллюстрирует пример с sqrt.

Глава 1. Построение абстракций с помощью процедур

44

sqrt

sqrt-iter

good-enough improve

square abs

average

Рис. 1.2. Процедурная декомпозиция программы sqrt.

Заметим, что задача вычисления квадратных корней естественным образом разбивается на подзадачи: как понять, что очередное приближение нас устраивает, как улучшить очередное приближение, и так далее. Каждая из этих задач решается с помощью
отдельной процедуры. Вся программа sqrt может рассматриваться как пучок процедур
(показанный на рис. 1.1.8), отражающий декомпозицию задачи на подзадачи.
Важность декомпозиционной стратегии не просто в том, что задача разделяется на
части. В конце концов, можно взять любую большую программу и поделить ее на части:
первые десять строк, следующие десять строк и так далее. Существенно то, что каждая процедура выполняет точно определенную задачу, которая может быть использована
при определении других процедур. Например, когда мы определяем процедуру goodenough? с помощью square, мы можем рассматривать процедуру square как «черный
ящик». В этот момент нас не интересует, как она вычисляет свой результат, — важно
только то, что она способна вычислить квадрат. О деталях того, как вычисляют квадраты, можно сейчас забыть и рассмотреть их потом. Действительно, пока мы рассматриваем процедуру good-enough?, square — не совсем процедура, но скорее абстракция
процедуры, так называемая процедурная абстракция (procedural abstraction). На этом
уровне абстракции все процедуры, вычисляющие квадрат, одинаково хороши.
Таким образом, если рассматривать только возвращаемые значения, то следующие
две процедуры для возведения числа в квадрат будут неотличимы друг от друга. Каждая
из них принимает числовой аргумент и возвращает в качестве значения квадрат этого
числа25 .
(define (square x) (* x x))
(define (square x)
(exp (double (log x))))
(define (double x) (+ x x))

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

1.1. Элементы программирования

45

жет оказаться, что пользователь процедуры не сам ее написал, а получил от другого
программиста как черный ящик. От пользователя не должно требоваться знания, как
работает процедура, чтобы ее использовать.
Локальные имена
Одна из деталей реализации, которая не должна заботить пользователя процедуры —
это то, какие человек, писавший процедуру, выбрал имена для формальных параметров
процедуры. Таким образом, следующие две процедуры должны быть неотличимы:
(define (square x) (* x x))
(define (square y) (* y y))

Этот принцип — что значение процедуры не должно зависеть от имен параметров, которые выбрал ее автор, — может сначала показаться очевидным, однако он имеет глубокие
следствия. Простейшее из этих следствий состоит в том, что имена параметров должны
быть локальными в теле процедуры. Например, в программе вычисления квадратного
корня при определении good-enough? мы использовали square:
(define (good-enough? guess x)
(< (abs (- (square guess) x)) 0.001))

Намерение автора good-enough? состоит в том, чтобы определить, достаточно ли близко квадрат первого аргумента лежит ко второму. Мы видим, что автор good-enough?
обращается к первому аргументу с помощью имени guess, а ко второму с помощью
имени x. Аргументом square является guess. Поскольку автор square использовал
имя x (как мы видели выше), чтобы обратиться к этому аргументу, мы видим, что
x в good-enough? должно отличаться от x в square. Запуск процедуры square не
должен отразится на значении x, которое использует good-enough?, поскольку это
значение x понадобится good-enough?, когда square будет вычислена.
Если бы параметры не были локальны по отношению к телам своих процедур, то
параметр x в square смешался бы с параметром x из good-enough?, и поведение
good-enough? зависело бы от того, какую версию square мы использовали. Таким
образом, процедура square не была бы черным ящиком, как мы того хотим.
У формального параметра особая роль в определении процедуры: не имеет значения, какое у этого параметра имя. Такое имя называется связанной переменной (bound
variable), и мы будем говорить, что определение процедуры связывает (binds) свои формальные параметры. Значение процедуры не изменяется, если во всем ее определении
параметры последовательным образом переименованы26 . Если переменная не связана,
мы говорим, что она свободна (free). Множество выражений, для которых связывание
определяет имя, называется областью действия (scope) этого имени. В определении
процедуры связанные переменные, объявленные как формальные параметры процедуры,
имеют своей областью действия тело процедуры.
В приведенном выше определении good-enough?, guess и x — связанные переменные, а counter max-count)
product
(fact-iter (* counter product)
(+ counter 1)
max-count)))

Как и раньше, мы можем с помощью подстановочной модели изобразить процесс вычисления 6!, как показано на рис. 1.4.
Сравним эти два процесса. С одной стороны, они кажутся почти одинаковыми. Оба
они вычисляют одну и ту же математическую функцию с одной и той же областью
определения, и каждый из них для вычисления n! требует количества шагов, пропорционального n. Действительно, два этих процесса даже производят одну и ту же последовательность умножений и получают одну и ту же последовательность частичных
произведений. С другой стороны, когда мы рассмотрим «формы» этих двух процессов,
мы увидим, что они ведут себя совершенно по-разному
Возьмем первый процесс. Подстановочная модель показывает сначала серию расширений, а затем сжатие, как показывает стрелка на рис. 1.3. Расширение происходит по
мере того, как процесс строит цепочку отложенных операций (deferred operations), в
данном случае цепочку умножений. Сжатие происходит тогда, когда выполняются эти
отложенные операции. Такой тип процесса, который характеризуется цепочкой отложенных операций, называется рекурсивным процессом (recursive process). Выполнение
этого процесса требует, чтобы интерпретатор запоминал, какие операции ему нужно выполнить впоследствии. При вычислении n! длина цепочки отложенных умножений, а
следовательно, и объем информации, который требуется, чтобы ее сохранить, растет линейно с ростом n (пропорционален n), как и число шагов. Такой процесс называется
линейно рекурсивным процессом (linear recursive process).
Напротив, второй процесс не растет и не сжимается. На каждом шаге при любом значении n необходимо помнить лишь текущие значения переменных product, counter и
max-count. Такой процесс мы называем итеративным (iterative process).

1.2. Процедуры и порождаемые ими процессы

51

В общем случае, итеративный процесс — это такой процесс, состояние которого
можно описать конечным числом переменных состояния (state variables) плюс заранее
заданное правило, определяющее, как эти переменные состояния изменяются от шага к
шагу, и плюс (возможно) тест на завершение, который определяет условия, при которых
процесс должен закончить работу. При вычислении n! число шагов линейно растет с
ростом n. Такой процесс называется линейно итеративным процессом (linear iterative
process).
Можно посмотреть на различие этих двух процессов и с другой точки зрения. В
итеративном случае в каждый момент переменные программы дают полное описание
состояния процесса. Если мы остановим процесс между шагами, для продолжения вычислений нам будет достаточно дать интерпретатору значения трех переменных программы. С рекурсивным процессом это не так. В этом случае имеется дополнительная
«спрятанная» информация, которую хранит интерпретатор и которая не содержится в
переменных программы. Она указывает, «где находится» процесс в терминах цепочки
отложенных операций. Чем длиннее цепочка, тем больше информации нужно хранить30 .
Противопоставляя итерацию и рекурсию, нужно вести себя осторожно и не смешивать понятие рекурсивного процесса с понятием рекурсивной процедуры. Когда мы
говорим, что процедура рекурсивна, мы имеем в виду факт синтаксиса: определение процедуры ссылается (прямо или косвенно) на саму эту процедуру. Когда же мы говорим о
процессе, что он следует, скажем, линейно рекурсивной схеме, мы говорим о развитии
процесса, а не о синтаксисе, с помощью которого написана процедура. Может показаться
странным, например, высказывание «рекурсивная процедура fact-iter описывает итеративный процесс». Однако процесс действительно является итеративным: его состояние
полностью описывается тремя переменными состояния, и чтобы выполнить этот процесс,
интерпретатор должен хранить значение только трех переменных.
Различие между процессами и процедурами может запутывать отчасти потому, что
большинство реализаций обычных языков (включая Аду, Паскаль и Си) построены так,
что интерпретация любой рекурсивной процедуры поглощает объем памяти, линейно растущий пропорционально количеству вызовов процедуры, даже если описываемый ею процесс в принципе итеративен. Как следствие, эти языки способны описывать итеративные
процессы только с помощью специальных«циклических конструкций» вроде do, repeat,
until, for и while. Реализация Scheme, которую мы рассмотрим в главе 5, свободна
от этого недостатка. Она будет выполнять итеративный процесс, используя фиксированный объем памяти, даже если он описывается рекурсивной процедурой. Такое свойство
реализации языка называется поддержкой хвостовой рекурсии (tail recursion)∗ . Если
реализация языка поддерживает хвостовую рекурсию, то итерацию можно выразить с
помощью обыкновенного механизма вызова функций, так что специальные циклические
конструкции имеют смысл только как синтаксический сахар31 .
30 Когда в главе 5 мы будем обсуждать реализацию процедур с помощью регистровых машин, мы увидим, что
итеративный процесс можно реализовать «в аппаратуре» как машину, у которой есть только конечный набор
регистров и нет никакой дополнительной памяти. Напротив, для реализации рекурсивного процесса требуется
машина со вспомогательной структурой данных, называемойстек (stack).
∗ Словарь multitran.ru дает перевод «концевая рекурсия». Наш вариант, как кажется, изящнее и сохраняет метафору, содержащуюся в англоязычном термине. — прим. перев.
31 Довольно долго считалось, что хвостовая рекурсия — особый трюк в оптимизирующих компиляторах.
Ясное семантическое основание хвостовой рекурсии было найдено Карлом Хьюиттом (Hewitt 1977), который
выразил ее в терминах модели вычислений с помощью «передачи сообщений» (мы рассмотрим эту модель в

Глава 1. Построение абстракций с помощью процедур

52

Упражнение 1.9.
Каждая из следующих двух процедур определяет способ сложения двух положительных целых
чисел с помощью процедур inc, которая добавляет к своему аргументу 1, и dec, которая отнимает
от своего аргумента 1.
(define (+ a b)
(if (= a 0)
b
(inc (+ (dec a) b))))
(define (+ a b)
(if (= a 0)
b
(+ (dec a) (inc b))))
Используя подстановочную модель, проиллюстрируйте процесс, порождаемый каждой из этих процедур, вычислив (+ 4 5). Являются ли эти процессы итеративными или рекурсивными?
Упражнение 1.10.
Следующая процедура вычисляет математическую функцию, называемую функцией Аккермана.
(define (A x y)
(cond ((= y 0)
((= x 0)
((= y 1)
(else (A

0)
(* 2 y))
2)
(- x 1)
(A x (- y 1))))))

Каковы значения следующих выражений?
(A 1 10)
(A 2 4)
(A 3 3)
Рассмотрим следующие процедуры, где A — процедура, определенная выше:
(define (f n) (A 0 n))
(define (g n) (A 1 n))
(define (h n) (A 2 n))
(define (k n) (* 5 n n))
Дайте краткие математические определения функций, вычисляемых процедурами f, g и h для
положительных целых значений n. Например, (k n) вычисляет 5n2 .
главе 3). Вдохновленные этим, Джеральд Джей Сассман и Гай Льюис Стил мл. (см. Steele 1975) построили
интерпретатор Scheme с поддержкой хвостовой рекурсии. Позднее Стил показал, что хвостовая рекурсия
является следствием естественного способа компиляции вызовов процедур (Steele 1977). Стандарт Scheme
IEEE требует, чтобы все реализации Scheme поддерживали хвостовую рекурсию.

1.2. Процедуры и порождаемые ими процессы

53

1.2.2. Древовидная рекурсия
Существует еще одна часто встречающаяся схема вычислений, называемая древовидная рекурсия (tree recursion). В качестве примера рассмотрим вычисление последовательности чисел Фибоначчи, в которой каждое число является суммой двух предыдущих:
0, 1, 1, 2, 3, 5, 8, 13, 21, . . .
Общее правило для чисел Фибоначчи можно сформулировать так:

если n = 0
 0
1
если n = 1
Fib(n) =

Fib(n − 1) + Fib(n − 2) в остальных случаях
Можно немедленно преобразовать это определение в процедуру:
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1))
(fib (- n 2))))))

Рассмотрим схему этого вычисления. Чтобы вычислить (fib 5), мы сначала вычисляем (fib 4) и (fib 3). Чтобы вычислить (fib 4), мы вычисляем (fib 3) и
(fib 2). В общем, получающийся процесс похож на дерево, как показано на рис. 1.5.
Заметьте, что на каждом уровне (кроме дна) ветви разделяются надвое; это отражает
тот факт, что процедура fib при каждом вызове обращается к самой себе дважды.
Эта процедура полезна как пример прототипической древовидной рекурсии, но как
метод получения чисел Фибоначчи она ужасна, поскольку производит массу излишних
вычислений. Обратите внимание на рис. 1.5: все вычисление (fib 3) — почти половина
общей работы, — повторяется дважды. В сущности, нетрудно показать, что общее число
раз, которые эта процедура вызовет (fib 1) или (fib 0) (в общем, число листьев) в
точности равняется Fib(n+1). Чтобы понять, насколько это плохо, отметим, что значение
Fib(n) растет экспоненциально при увеличении
√ n. Более точно (см. упражнение 1.13),
Fib(n) — это целое число, ближайшее к φn / 5, где

φ = (1 + 5)/2 ≈ 1.6180
то есть золотое сечение (golden ratio), которое удовлетворяет уравнению
φ2 = φ + 1
Таким образом, число шагов нашего процесса растет экспоненциально при увеличении
аргумента. С другой стороны, требования к памяти растут при увеличении аргумента
всего лишь линейно, поскольку в каждой точке вычисления нам требуется запоминать
только те вершины, которые находятся выше нас по дереву. В общем случае число шагов,
требуемых древовидно-рекурсивным процессом, будет пропорционально числу вершин
дерева, а требуемый объем памяти будет пропорционален максимальной глубине дерева.
Для получения чисел Фибоначчи мы можем сформулировать итеративный процесс.
Идея состоит в том, чтобы использовать пару целых a и b, которым в начале даются

Глава 1. Построение абстракций с помощью процедур

54

fib 5

fib 4

fib 3

fib 2

fib 1

fib 3
fib 2

fib 2

fib 1

1

fib 1

fib 0

1

fib 1

1

fib 1

fib 0

1

0

1

fib 0

0

0

Рис. 1.5. Древовидно-рекурсивный процесс, порождаемый при вычислении (fib 5).

1.2. Процедуры и порождаемые ими процессы

55

значения Fib(1) = 1 и Fib(0) = 0, и на каждом шаге применять одновременную трансформацию
a← a+b
b←a
Нетрудно показать, что после того, как мы проделаем эту трансформацию n раз, a и b
будут соответственно равны Fib(n + 1) и Fib(n). Таким образом, мы можем итеративно
вычислять числа Фибоначчи при помощи процедуры
(define (fib n)
(fib-iter 1 0 n))
(define (fib-iter a b count)
(if (= count 0)
b
(fib-iter (+ a b) a (- count 1))))

Второй метод вычисления чисел Фибоначчи представляет собой линейную итерацию.
Разница в числе шагов, требуемых двумя этими методами — один пропорционален n,
другой растет так же быстро, как и само Fib(n), — огромна, даже для небольших
значений аргумента.
Не нужно из этого делать вывод, что древовидно-рекурсивные процессы бесполезны. Когда мы будем рассматривать процессы, работающие не с числами, а с иерархически структурированными данными, мы увидим, что древовидная рекурсия является
естественным и мощным инструментом32. Но даже при работе с числами древовиднорекурсивные процессы могут быть полезны — они помогают нам понимать и проектировать программы. Например, хотя первая процедура fib и намного менее эффективна,
чем вторая, зато она проще, поскольку это немногим более, чем перевод определения
последовательности чисел Фибоначчи на Лисп. Чтобы сформулировать итеративный алгоритм, нам пришлось заметить, что вычисление можно перестроить в виде итерации с
тремя переменными состояния.
Размен денег
Чтобы сочинить итеративный алгоритм для чисел Фибоначчи, нужно совсем немного
смекалки. Теперь для контраста рассмотрим следующую задачу: сколькими способами
можно разменять сумму в 1 доллар, если имеются монеты по 50, 25, 10, 5 и 1 цент?
В более общем случае, можно ли написать процедуру подсчета способов размена для
произвольной суммы денег?
У этой задачи есть простое решение в виде рекурсивной процедуры. Предположим,
мы как-то упорядочили типы монет, которые у нас есть. В таком случае верно будет
следующее уравнение:
Число способов разменять сумму a с помощью n типов монет равняется
• числу способов разменять сумму a с помощью всех типов монет, кроме первого,
плюс
32 Пример этого был упомянут в разделе 1.1.3: сам интерпретатор вычисляет выражения с помощью древовидно-рекурсивного процесса.

Глава 1. Построение абстракций с помощью процедур

56

• число способов разменять сумму a − d с использованием всех n типов монет, где
d — достоинство монет первого типа.
Чтобы увидеть, что это именно так, заметим, что способы размена могут быть поделены на две группы: те, которые не используют первый тип монеты, и те, которые
его используют. Следовательно, общее число способов размена какой-либо суммы равно
числу способов разменять эту сумму без привлечения монет первого типа плюс число
способов размена в предположении, что мы этот тип используем. Но последнее число
равно числу способов размена для суммы, которая остается после того, как мы один раз
употребили первый тип монеты.
Таким образом, мы можем рекурсивно свести задачу размена данной суммы к задаче размена меньших сумм с помощью меньшего количества типов монет. Внимательно
рассмотрите это правило редукции и убедите себя, что мы можем использовать его для
описания алгоритма, если укажем следующие вырожденные случаи33 :
• Если a в точности равно 0, мы считаем, что имеем 1 способ размена.

• Если a меньше 0, мы считаем, что имеем 0 способов размена.

• Если n равно 0, мы считаем, что имеем 0 способов размена.
Это описание легко перевести в рекурсивную процедуру:
(define (count-change amount)
(cc amount 5))

(define (cc amount kinds-of-coins)
(cond ((= amount 0) 1)
((or (< amount 0) (= kinds-of-coins 0)) 0)
(else (+ (cc amount
(- kinds-of-coins 1))
(cc (- amount
(first-denomination kinds-of-coins))
kinds-of-coins)))))
(define (first-denomination kinds-of-coins)
(cond ((= kinds-of-coins 1) 1)
((= kinds-of-coins 2) 5)
((= kinds-of-coins 3) 10)
((= kinds-of-coins 4) 25)
((= kinds-of-coins 5) 50)))

(Процедура first-denomination принимает в качестве входа число доступных типов
монет и возвращает достоинство первого типа. Здесь мы упорядочили монеты от самой
крупной к более мелким, но годился бы и любой другой порядок.) Теперь мы можем
ответить на исходный вопрос о размене доллара:
(count-change 100)
292
33 Рассмотрите для примера в деталях, как применяется правило редукции, если нужно разменять 10 центов
на монеты в 1 и 5 центов.

1.2. Процедуры и порождаемые ими процессы

57

Count-change порождает древовидно-рекурсивный процесс с избыточностью, похожей на ту, которая возникает в нашей первой реализации fib. (На то, чтобы получить
ответ 292, уйдет заметное время.) С другой стороны, неочевидно, как построить более
эффективный алгоритм для получения этого результата, и мы оставляем это в качестве
задачи для желающих. Наблюдение, что древовидная рекурсия может быть весьма неэффективна, но зато ее часто легко сформулировать и понять, привело исследователей к
мысли, что можно получить лучшее из двух миров, если спроектировать «умный компилятор», который мог бы трансформировать древовидно-рекурсивные процедуры в более
эффективные, но вычисляющие тот же результат34 .
Упражнение 1.11.
Функция f определяется правилом: f (n) = n, если n < 3, и f (n) = f (n − 1) + f (n − 2) + f (n − 3),
если n ≥ 3. Напишите процедуру, вычисляющую f с помощью рекурсивного процесса. Напишите
процедуру, вычисляющую f с помощью итеративного процесса.
Упражнение 1.12.
Приведенная ниже таблица называется треугольником Паскаля (Pascal’s triangle).
1
1
1
1
1

1
2

3
4

1
3

6
...

1
4

1

Все числа по краям треугольника равны 1, а каждое число внутри треугольника равно сумме двух
чисел над ним35 . Напишите процедуру, вычисляющую элементы треугольника Паскаля с помощью
рекурсивного процесса.
Упражнение 1.13.


Докажите, что Fib(n) есть целое число, ближайшее к φn / 5, где φ = (1 + 5)/2. Указание:

пусть ψ = (1 − 5)/2. С помощью √
определения чисел Фибоначчи (см. раздел 1.2.2) и индукции
докажите, что Fib(n) = (φn − ψ n )/ 5.
34 Один из способов избежать избыточных вычислений состоит в том, чтобы автоматически строить таблицу
значений по мере того, как они вычисляются. Каждый раз, когда нужно применить процедуру к какомунибудь аргументу, мы могли бы сначала обращаться к таблице, смотреть, не хранится ли в ней уже значение,
и в этом случае мы избежали бы избыточного вычисления. Такая стратегия, называемая табуляризацией
(tabulation) или мемоизацией (memoization), легко реализуется. Иногда с помощью табуляризации можно
преобразовать процессы, требующие экспоненциального числа шагов (вроде count-change), в процессы,
требования которых к времени и памяти линейно растут по мере роста ввода. См. упражнение 3.27.
35 Элементы треугольника Паскаля называются биномиальными коэффициентами (binomial coefficients),
поскольку n-й ряд состоит из коэффициентов термов при разложении (x + y)n . Эта схема вычисления коэффициентов появилась в передовой работе Блеза Паскаля 1653 года по теории вероятностей Traité du triangle
arithmétique. Согласно Knuth 1973, та же схема встречается в труде Цзу-юань Юй-чэнь («Драгоценное зеркало
четырех элементов»), опубликованном китайским математиком Цзю Ши-Цзе в 1303 году, в трудах персидского
поэта и математика двенадцатого века Омара Хайяма и в работах индийского математика двенадцатого века
Бхаскары Ачарьи.

58

Глава 1. Построение абстракций с помощью процедур

1.2.3. Порядки роста
Предшествующие примеры показывают, что процессы могут значительно различаться
по количеству вычислительных ресурсов, которые они потребляют. Удобным способом
описания этих различий является понятие порядка роста (order of growth), которое дает
общую оценку ресурсов, необходимых процессу при увеличении его входных данных.
Пусть n — параметр, измеряющий размер задачи, и пусть R(n) — количество ресурсов, необходимых процессу для решения задачи размера n. В предыдущих примерах n
было числом, для которого требовалось вычислить некоторую функцию, но возможны и
другие варианты. Например, если требуется вычислить приближение к квадратному корню числа, то n может быть числом цифр после запятой, которые нужно получить. В
задаче умножения матриц n может быть количеством рядов в матрицах. Вообще говоря, может иметься несколько характеристик задачи, относительно которых желательно
проанализировать данный процесс. Подобным образом, R(n) может измерять количество
используемых целочисленных регистров памяти, количество исполняемых элементарных
машинных операций, и так далее. В компьютерах, которые выполняют определенное
число операций за данный отрезок времени, требуемое время будет пропорционально
необходимому числу элементарных машинных операций.
Мы говорим, что R(n) имеет порядок роста Θ(f (n)), что записывается R(n) =
Θ(f (n)) и произносится «тета от f (n)», если существуют положительные постоянные k1
и k2 , независимые от n, такие, что
k1 f (n) ≤ R(n) ≤ k2 f (n)
для всякого достаточно большого n. (Другими словами, значение R(n) заключено между
k1 f (n) и k2 f (n).)
Например, для линейно рекурсивного процесса вычисления факториала, описанного в разделе 1.2.1, число шагов растет пропорционально входному значению n. Таким
образом, число шагов, необходимых этому процессу, растет как Θ(n). Мы видели также, чтотребуемый объем памятирастет как Θ(n). Для итеративного факториала число
шагов по-прежнему Θ(n), но объем памяти Θ(1) — то есть константа36 . Древовиднорекурсивное вычисление чисел Фибоначчи требует Θ(φn ) шагов и Θ(n) памяти, где φ —
золотое сечение, описанное в разделе 1.2.2.
Порядки роста дают всего лишь грубое описание поведения процесса. Например,
процесс, которому требуется n2 шагов, процесс, которому требуется 1000n2 шагов и процесс, которому требуется 3n2 + 10n + 17 шагов — все имеют порядок роста Θ(n2 ). С
другой стороны, порядок роста показывает, какого изменения можно ожидать в поведении процесса, когда мы меняем размер задачи. Для процесса с порядком роста Θ(n)
(линейного) удвоение размера задачи примерно удвоит количество используемых ресурсов. Для экспоненциального процесса каждое увеличение размера задачи на единицу
будет умножать количество ресурсов на постоянный коэффициент. В оставшейся части
раздела 1.2 мы рассмотрим два алгоритма, которые имеют логарифмический порядок ро36 В этих утверждениях скрывается важное упрощение. Например, если мы считаем шаги процесса как
«машинные операции», мы предполагаем, что число машинных операций, нужных, скажем, для вычисления
произведения, не зависит от размера умножаемых чисел, а это становится неверным при достаточно больших
числах. Те же замечания относятся и к оценке требуемой памяти. Подобно проектированию и описанию
процесса, анализ процесса может происходить на различных уровнях абстракции.

1.2. Процедуры и порождаемые ими процессы

59

ста, так что удвоение размера задачи увеличивает требования к ресурсам на постоянную
величину.
Упражнение 1.14.
Нарисуйте дерево, иллюстрирующее процесс, который порождается процедурой count-change из
раздела 1.2.2 при размене 11 центов. Каковы порядки роста памяти и числа шагов, используемых
этим процессом при увеличении суммы, которую требуется разменять?
Упражнение 1.15.
Синус угла (заданного в радианах) можно вычислить, если воспользоваться приближением sin x ≈
x при малых x и употребить тригонометрическое тождество
sin x = 3 sin

x
x
− 4 sin3
3
3

для уменьшения значения аргумента sin. (В этом упражнении мы будем считать, что угол «достаточно мал», если он не больше 0.1 радиана.) Эта идея используется в следующих процедурах:
(define (cube x) (* x x x))
(define (p x) (- (* 3 x) (* 4 (cube x))))
(define (sine angle)
(if (not (> (abs angle) 0.1))
angle
(p (sine (/ angle 3.0)))))
а. Сколько раз вызывается процедура p при вычислении (sine 12.15)?
б. Каковы порядки роста в терминах количества шагов и используемой памяти (как функция a)
для процесса, порождаемого процедурой sine при вычислении (sine a)?

1.2.4. Возведение в степень
Рассмотрим задачу возведения числа в степень. Нам нужна процедура, которая, приняв в качестве аргумента основание b и положительное целое значение степени n, возвращает bn . Один из способов получить желаемое — через рекурсивное определение
bn
b0

=
=

b · bn−1
1

которое прямо переводится в процедуру
(define (expt b n)
(if (= n 0)
1
(* b (expt b (- n 1)))))

Это линейно рекурсивный процесс, требующий Θ(n) шагов и Θ(n) памяти. Подобно
факториалу, мы можем немедленно сформулировать эквивалентную линейную итерацию:

60

Глава 1. Построение абстракций с помощью процедур

(define (expt b n)
(expt-iter b n 1))
(define (expt-iter b counter product)
(if (= counter 0)
product
(expt-iter b
(- counter 1)
(* b product))))

Эта версия требует Θ(n) шагов и Θ(1) памяти.
Можно вычислять степени за меньшее число шагов, если использовать последовательное возведение в квадрат. Например, вместо того, чтобы вычислять b8 в виде
b · (b · (b · (b · (b · (b · (b · b))))))

мы можем вычислить его за три умножения:

b2 = b · b
b4 = b2 · b2
b8 = b4 · b4

Этот метод хорошо работает для степеней, которые сами являются степенями двойки. В общем случае при вычислении степеней мы можем получить преимущество от
последовательного возведения в квадрат, если воспользуемся правилом
bn = (bn/2 )2
bn = b · bn−1

если n четно
если n нечетно

Этот метод можно выразить в виде процедуры
(define (fast-expt b n)
(cond ((= n 0) 1)
((even? n) (square (fast-expt b (/ n 2))))
(else (* b (fast-expt b (- n 1))))))

где предикат, проверяющий целое число на четность, определен через элементарную
процедуру remainder:
(define (even? n)
(= (remainder n 2) 0))

Процесс, вычисляющий fast-expt, растет логарифмически как по используемой памяти, так и по количеству шагов. Чтобы увидеть это, заметим, что вычисление b2n с
помощью этого алгоритма требует всего на одно умножение больше, чем вычисление
bn . Следовательно, размер степени, которую мы можем вычислять, возрастает примерно
вдвое с каждым следующим умножением, которое нам разрешено делать. Таким образом, число умножений, требуемых для вычисления степени n, растет приблизительно так
же быстро, как логарифм n по основанию 2. Процесс имеет степень роста Θ(log(n))37 .
37 Точнее, количество требуемых умножений равно логарифму n по основанию 2 минус 1 и плюс количество
единиц в двоичном представлении n. Это число всегда меньше, чем удвоенный логарифм n по основанию 2.
Произвольные константы k1 и k2 в определении порядка роста означают, что для логарифмического процесса
основание, по которому берется логарифм, не имеет значения, так что все такие процессы описываются как
Θ(log(n)).

1.2. Процедуры и порождаемые ими процессы

61

Если n велико, разница между порядком роста Θ(log(n)) и Θ(n) оказывается очень
заметной. Например, fast-expt при n = 1000 требует всего 14 умножений38. С помощью идеи последовательного возведения в квадрат можно построить также итеративный
алгоритм, который вычисляет степени за логарифмическое число шагов (см. упражнение 1.16), хотя, как это часто бывает с итеративными алгоритмами, его нельзя записать
так же просто, как рекурсивный алгоритм39 .
Упражнение 1.16.
Напишите процедуру, которая развивается в виде итеративного процесса и реализует возведение в
степень за логарифмическое число шагов, как fast-expt. (Указание: используя наблюдение, что
(bn/2 )2 = (b2 )n/2 , храните, помимо значения степени n и основания b, дополнительную переменную состояния a, и определите переход между состояниями так, чтобы произведение abn от шага к
шагу не менялось. Вначале значение a берется равным 1, а ответ получается как значение a в
момент окончания процесса. В общем случае метод определения инварианта (invariant quantity),
который не изменяется при переходе между шагами, является мощным способом размышления о
построении итеративных алгоритмов.)
Упражнение 1.17.
Алгоритмы возведения в степень из этого раздела основаны на повторяющемся умножении. Подобным же образом можно производить умножение с помощью повторяющегося сложения. Следующая
процедура умножения (в которой предполагается, что наш язык способен только складывать, но
не умножать) аналогична процедуре expt:
(define (* a b)
(if (= b 0)
0
(+ a (* a (- b 1)))))
Этот алгоритм затрачивает количество шагов, линейно пропорциональное b. Предположим теперь,
что, наряду со сложением, у нас есть операции double, которая удваивает целое число, и halve,
которая делит (четное) число на 2. Используя их, напишите процедуру, аналогичную fast-expt,
которая затрачивает логарифмическое число шагов.
Упражнение 1.18.
Используя результаты упражнений 1.16 и 1.17, разработайте процедуру, которая порождает итеративный процесс для умножения двух чисел с помощью сложения, удвоения и деления пополам, и
затрачивает логарифмическое число шагов40.
Упражнение 1.19.
Существует хитрый алгоритм получения чисел Фибоначчи за логарифмическое число шагов.
Вспомните трансформацию переменных состояния a и b процесса fib-iter из раздела 1.2.2:
38 Если Вас интересует, зачем это кому-нибудь может понадобиться возводить числа в 1000-ю степень,
смотрите раздел 1.2.6.
39 Итеративный алгоритм очень стар. Он встречается в Чанда-сутре Ачарьи Пингалы, написанной до 200
года до н.э. В Knuth 1981, раздел 4.6.3, содержится полное обсуждение и анализ этого и других методов
возведения в степень.
40 Этот алгоритм, который иногда называют «методом русского крестьянина», очень стар. Примеры его использования найдены в Риндском папирусе, одном из двух самых древних существующих математических
документов, который был записан (и при этом скопирован с еще более древнего документа) египетским писцом по имени А’х-мосе около 1700 г. до н.э.

Глава 1. Построение абстракций с помощью процедур

62

a ← a + b и b ← a. Назовем эту трансформацию T и заметим, что n-кратное применение T , начиная с 1 и 0, дает нам пару Fib(n + 1) и Fib(n). Другими словами, числа Фибоначчи получаются
путем применения T n , n-ой степени трансформации T , к паре (1,0). Теперь рассмотрим T как
частный случай p = 0, q = 1 в семействе трансформаций Tpq , где Tpq преобразует пару (a, b) по
правилу a ← bq + aq + ap, b ← bp + aq. Покажите, что двукратное применение трансформации
Tpq равносильно однократному применению трансформации Tp′ q′ того же типа, и вычислите p′ и
q ′ через p и q. Это дает нам прямой способ возводить такие трансформации в квадрат, и таким
образом, мы можем вычислить T n с помощью последовательного возведения в квадрат, как в
процедуре fast-expt. Используя все эти идеи, завершите следующую процедуру, которая дает
результат за логарифмическое число шагов41:
(define (fib n)
(fib-iter 1 0 0 1 n))
(define (fib-iter a b p q count)
(cond ((= count 0) b)
((even? count)
(fib-iter a
b
h??i ; вычислить p’
h??i ; вычислить q’
(/ count 2)))
(else (fib-iter (+ (* b q) (* a q) (* a p))
(+ (* b p) (* a q))
p
q
(- count 1)))))

1.2.5. Нахождение наибольшего общего делителя
По определению, наибольший общий делитель (НОД) двух целых чисел a и b — это
наибольшее целое число, на которое и a, и b делятся без остатка. Например, НОД 16 и 28
равен 4. В главе 2, когда мы будем исследовать реализацию арифметики на рациональных
числах, нам потребуется вычислять НОДы, чтобы сокращать дроби. (Чтобы сократить
дробь, нужно поделить ее числитель и знаменатель на их НОД. Например, 16/28 сокращается до 4/7.) Один из способов найти НОД двух чисел состоит в том, чтобы разбить
каждое из них на простые множители и найти среди них общие, однако существует
знаменитый и значительно более эффективный алгоритм.
Этот алгоритм основан на том, что если r есть остаток от деления a на b, то общие
делители a и b в точности те же, что и общие делители b и r. Таким образом, можно
воспользоваться уравнением
НОД(a, b) = НОД(b, r)
чтобы последовательно свести задачу нахождения НОД к задаче нахождения НОД все
41 Это

упражнение нам предложил Джо Стойна основе примера из Kaldewaij 1990.

1.2. Процедуры и порождаемые ими процессы

63

меньших и меньших пар целых чисел. Например,
НОД(206, 40) =
=
=
=
=

НОД(40, 6)
НОД(6, 4)
НОД(4, 2)
НОД(2, 0)
2

сводит НОД(206, 40) к НОД(2, 0), что равняется двум. Можно показать, что если начать с произвольных двух целых чисел и производить последовательные редукции, в
конце концов всегда получится пара, где вторым элементом будет 0. Этот способ нахождения НОД известен как алгоритм Евклида (Euclid’s Algorithm)42 .
Алгоритм Евклида легко выразить в виде процедуры:
(define (gcd a b)
(if (= b 0)
a
(gcd b (remainder a b))))

Она порождает итеративный процесс, число шагов которого растет пропорционально
логарифму чисел-аргументов.
Тот факт, что число шагов, затрачиваемых алгоритмом Евклида, растет логарифмически, интересным образом связан с числами Фибоначчи:
Теорема Ламэ:
Если алгоритму Евклида требуется k шагов для вычисления НОД некоторой
пары чисел, то меньший из членов этой пары больше или равен k-тому числу
Фибоначчи43.
С помощью этой теоремы можно оценить порядок роста алгоритма Евклида. Пусть
n будет меньшим из двух аргументов процедуры. Если процесс завершается за k шагов,
42 Алгоритм Евклида называется так потому, что он встречается в Началах Евклида (книга 7, ок. 300 г. до
н.э.). По утверждению Кнута (Knuth 1973), его можно считать самым старым из известных нетривиальных
алгоритмов. Древнеегипетский метод умножения (упражнение 1.18), разумеется, древнее, но, как объясняет
Кнут, алгоритм Евклида — самый старый алгоритм, представленный в виде общей процедуры, а не через набор
иллюстрирующих примеров.
43 Эту теорему доказал в 1845 году Габриэль Ламэ, французский математик и инженер, который больше
всего известен своим вкладом в математическую физику. Чтобы доказать теорему, рассмотрим пары (ak , bk ),
где ak ≥ bk и алгоритм Евклида завершается за k шагов. Доказательство основывается на утверждении,
что если (ak+1 , bk+1 ) → (ak , bk ) → (ak−1 , bk−1 ) — три последовательные пары в процессе редукции, то
bk+1 ≥ bk + bk−1 . Чтобы доказать это утверждение, вспомним, что шаг редукции определяется применением
трансформации ak−1 = bk , bk−1 = остаток от деления ak на bk . Второе из этих уравнений означает, что
ak = qbk + bk−1 для некоторого положительного числа q. Поскольку q должно быть не меньше 1, имеем
ak = qbk + bk−1 ≥ bk + bk−1 . Но из предыдущего шага редукции мы имеем bk+1 = ak . Таким образом,
bk+1 = ak ≥ bk + bk−1 . Промежуточное утверждение доказано. Теперь можно доказать теорему индукцией по
k, то есть числу шагов, которые требуются алгоритму для завершения. Утверждение теоремы верно при k = 1,
поскольку при этом требуется всего лишь чтобы b было не меньше, чем Fib(1) = 1. Теперь предположим, что
утверждение верно для всех чисел, меньших или равных k, и докажем его для k + 1. Пусть (ak+1 , bk+1 ) →
(ak , bk ) → (ak−1 , bk−1 ) — последовательные пары в процессе редукции. Согласно гипотезе индукции, bk−1 ≥
Fib(k − 1), bk ≥ Fib(k). Таким образом, применение промежуточного утверждения совместно с определением
чисел Фибоначчи дает bk+1 ≥ bk + bk−1 ≥ Fib(k) + Fib(k − 1) = Fib(k + 1), что и доказывает теорему Ламэ.

Глава 1. Построение абстракций с помощью процедур

64


должно выполняться n ≥ Fib(k) ≈ φk / 5. Следовательно, число шагов k растет как
логарифм n (по основанию φ). Следовательно, порядок роста равен Θ(log n).
Упражнение 1.20.
Процесс, порождаемый процедурой, разумеется, зависит от того, по каким правилам работает интерпретатор. В качестве примера рассмотрим итеративную процедуру gcd, приведенную выше.
Предположим, что мы вычисляем эту процедуру с помощью нормального порядка, описанного в
разделе 1.1.5. (Правило нормального порядка вычислений для if описано в упражнении 1.5.)
Используя подстановочную модель для нормального порядка, проиллюстрируйте процесс, порождаемый при вычислении (gcd 206 40) и укажите, какие операции вычисления остатка действительно выполняются. Сколько операций remainder выполняется на самом деле при вычислении
(gcd 206 40) в нормальном порядке? При вычислении в аппликативном порядке?

1.2.6. Пример: проверка на простоту
В этом разделе
√ описываются два метода проверки числа n на простоту, один с порядком роста Θ( n), и другой, «вероятностный», алгоритм с порядком роста Θ(log n).
В упражнениях, приводимых в конце раздела, предлагаются программные проекты на
основе этих алгоритмов.
Поиск делителей
С древних времен математиков завораживали проблемы, связанные с простыми числами, и многие люди занимались поисками способов выяснить, является ли число простым. Один из способов проверки числа на простоту состоит в том, чтобы найти делители
числа. Следующая программа находит наименьший целый делитель (больший 1) числа
n. Она проделывает это «в лоб», путем проверки делимости n на все последовательные
числа, начиная с 2.
(define (smallest-divisor n)
(find-divisor n 2))
(define (find-divisor n test-divisor)
(cond ((> (square test-divisor) n) n)
((divides? test-divisor n) test-divisor)
(else (find-divisor n (+ test-divisor 1)))))
(define (divides? a b)
(= (remainder b a) 0))

Мы можем проверить, является ли число простым, следующим образом: n простое
тогда и только тогда, когда n само является своим наименьшим делителем.
(define (prime? n)
(= n (smallest-divisor n)))

Тест на завершение основан на том,
√ что если число n не простое, у него должен
быть делитель, меньше или равный n44 . Это означает, что алгоритм может проверять
44 Если

d — делитель n, то n/d тоже. Но d и n/d не могут оба быть больше



n.

1.2. Процедуры и порождаемые ими процессы

65


делители только от 1 до n. Следовательно, число шагов,
√ которые требуются, чтобы
определить, что n простое, будет иметь порядок роста Θ( n).
Тест Ферма
Тест на простоту с порядком роста Θ(log n) основан на утверждении из теории чисел,
известном как Малая теорема Ферма45 .
Малая теорема Ферма:
Если n — простое число, а a — произвольное целое число меньше, чем n, то
a, возведенное в n-ю степень, равно a по модулю n.
(Говорят, что два числа равны по модулю n (congruent modulo n), если они дают одинаковый остаток при делении на n. Остаток от деления числа a на n называется также
остатком a по модулю n (remainder of a modulo n) или просто a по модулю n.)
Если n не является простым, то, вообще говоря, большинство чисел a < n не будут
удовлетворять этому условию. Это приводит к следующему алгоритму проверки на простоту:имея число n, случайным образом выбрать число a < n и вычислить остаток от an
по модулю n. Если этот остаток не равен a, то n определенно не является простым. Если
он равен a, то мы имеем хорошие шансы, что n простое. Тогда нужно взять еще одно
случайное a и проверить его тем же способом. Если и оно удовлетворяет уравнению,
мы можем быть еще более уверены, что n простое. Испытывая все большее количество
a, мы можем увеличивать нашу уверенность в результате. Этот алгоритм называется
тестом Ферма.
Для реализации теста Ферма нам нужна процедура, которая вычисляет степень числа
по модулю другого числа:
(define (expmod base exp m)
(cond ((= exp 0) 1)
((even? exp)
(remainder (square (expmod base (/ exp 2) m))
m))
(else
(remainder (* base (expmod base (- exp 1) m))
m))))

Эта процедура очень похожа на fast-expt из раздела 1.2.4. Она использует последовательное возведение в квадрат, так что число шагов логарифмически растет с увеличением
степени46 .
45 Пьер де Ферма (1601-1665) считается основателем современной теории чисел. Он доказал множество важных теорем, однако, как правило, он объявлял только результаты, не публикуя своих доказательств. Малая
теорема Ферма была сформулирована в письме, которое он написал в 1640-м году. Первое опубликованное
доказательство было даноЭйлером в 1736 г. (более раннее, идентичное доказательство было найдено в неопубликованных рукописях Лейбница). Самый знаменитый результат Ферма, известный как Большая теорема
Ферма, был записан в 1637 году в его экземпляре книги Арифметика (греческого математика третьего века
Диофанта) с пометкой «я нашел подлинно удивительное доказательство, но эти поля слишком малы, чтобы
вместить его». Доказательство Большой теоремы Ферма стало одним из самых известных вопросов теории
чисел. Полное решение было найдено в 1995 году Эндрю Уайлсом из Принстонского университета.
46 Шаги редукции для случаев, когда степень больше 1, основаны на том, что для любых целых чисел x,
y и m мы можем найти остаток от деления произведения x и y на m путем отдельного вычисления остатков

66

Глава 1. Построение абстракций с помощью процедур

Тест Ферма производится путем случайного выбора числа a между 1 и n − 1 включительно и проверки, равен ли a остаток по модулю n от n-ой степени a. Случайное
число a выбирается с помощью процедуры random, про которую мы предполагаем, что
она встроена в Scheme в качестве элементарной процедуры. Random возвращает неотрицательное число, меньшее, чем ее целый аргумент. Следовательно, чтобы получить
случайное число между 1 и n − 1, мы вызываем random с аргументом n − 1 и добавляем
к результату 1:
(define (fermat-test n)
(define (try-it a)
(= (expmod a n n) a))
(try-it (+ 1 (random (- n 1)))))

Следующая процедура прогоняет тест заданное число раз, как указано ее параметром.
Ее значение истинно, если тест всегда проходит, и ложно в противном случае.
(define (fast-prime? n times)
(cond ((= times 0) true)
((fermat-test n) (fast-prime? n (- times 1)))
(else false)))

Вероятностные методы
Тест Ферма отличается по своему характеру от большинства известных алгоритмов,
где вычисляется результат, истинность которого гарантирована. Здесь полученный результат верен лишь с какой-то вероятностью. Более точно, если n не проходит тест
Ферма, мы можем точно сказать, что оно не простое. Но то, что n проходит тест, хотя и является очень сильным показателем, все же не гарантирует, что n простое. Нам
хотелось бы сказать, что для любого числа n, если мы проведем тест достаточное количество раз и n каждый раз его пройдет, то вероятность ошибки в нашем тесте на
простоту может быть сделана настолько малой, насколько мы того пожелаем.
К сожалению, это утверждение неверно. Существуют числа, которые «обманывают»
тест Ферма: числа, которые не являются простыми и тем не менее обладают свойством,
что для всех целых чисел a < n an равно a по модулю n. Такие числа очень редки,
так что на практике тест Ферма вполне надежен47 . Существуют варианты теста Ферма,
которые обмануть невозможно. В таких тестах, подобно методу Ферма, проверка числа
n на простоту ведется путем выбора случайного числа a < n и проверки некоторого
условия, зависящего от n и a. (Пример такого теста см. в упражнении 1.28.) С другой
x по модулю m, y по модулю m, перемножения их, и взятия остатка по модулю m от результата. Например, в
случае, когда e четно, мы можем вычислить остаток be/2 по модулю m, возвести его в квадрат и взять
остаток по модулю m. Такой метод полезен потому, что с его помощью мы можем производить вычисления, не
используя чисел, намного больших, чем m. (Сравните с упражнением 1.25.)
47 Числа, «обманывающие» тест Ферма, называются числами Кармайкла (Carmichael numbers), и про них
почти ничего неизвестно, кроме того, что они очень редки. Существует 255 чисел Кармайкла, меньших 100
000 000. Несколько первых — 561, 1105, 1729, 2465, 2821 и 6601. При проверке на простоту больших чисел,
выбранных случайным образом, шанс наткнуться на число, «обманывающее» тест Ферма, меньше, чем шанс,
что космическое излучение заставит компьютер сделать ошибку при вычислении «правильного» алгоритма. То,
что по первой из этих причин алгоритм считается неадекватным, а по второй нет, показывает разницу между
математикой и техникой.

1.2. Процедуры и порождаемые ими процессы

67

стороны, в отличие от теста Ферма, можно доказать, что для любого n условие не
выполняется для большинства чисел a < n, если n не простое. Таким образом, если n
проходит тест для какого-то случайного a, шансы, что n простое, уже больше половины.
Если n проходит тест для двух случайных a, шансы, что n простое, больше, чем 3 из 4.
Проводя тест с большим количеством случайных чисел, мы можем сделать вероятность
ошибки сколь угодно малой.
Существование тестов, для которых можно доказать, что вероятность ошибки можно сделать сколь угодно малой, вызвало большой интерес к алгоритмам такого типа.
Их стали называть вероятностными алгоритмами (probabilistic alorithms). В этой области ведутся активные исследования, и вероятностные алгоритмы удалось с успехом
применить во многих областях48 .
Упражнение 1.21.
С помощью процедуры smallest-divisor найдите наименьший делитель следующих чисел:
199, 1999, 19999.
Упражнение 1.22.
Бо́льшая часть реализаций Лиспа содержат элементарную процедуру runtime, которая возвращает целое число, показывающее, как долго работала система (например, в миллисекундах).
Следующая процедура timed-prime-test, будучи вызвана с целым числом n, печатает n и проверяет, простое ли оно. Если n простое, процедура печатает три звездочки и количество времени,
затраченное на проверку.
(define (timed-prime-test n)
(newline)
(display n)
(start-prime-test n (runtime)))
(define (start-prime-test n start-time)
(if (prime? n)
(report-prime (- (runtime) start-time))))
(define (report-prime elapsed-time)
(display " *** ")
(display elapsed-time))
Используя эту процедуру, напишите процедуру search-for-primes, которая проверяет на простоту все нечетные числа в заданном диапазоне. С помощью этой процедуры найдите наименьшие
три простых числа после 1000; после 10 000; после 100 000; после 1 000 000. Посмотрите, сколько
времени затрачивается на каждое простое число. Поскольку алгоритм проверки имеет порядок

роста Θ( n), Вам следовало бы ожидать, что проверка на простоту чисел, близких к 10 000,
48 Одно из наиболее впечатляющих применений вероятностные алгоритмы получили в области криптографии.
Хотя в настоящее время вычислительных ресурсов недостаточно, чтобы разложить на множители произвольное число из 200 цифр, с помощью теста Ферма проверить, является ли оно простым, можно за несколько
секунд. Этот факт служит основой предложенного в Rivest, Shamir, and Adleman 1977 метода построения
шифров, которые «невозможно» взломать. Полученный алгоритм RSA (RSA algorithm) стал широко используемым методом повышения секретности электронных средств связи. В результате этого и других связанных
событий исследование простых чисел, которое раньше считалось образцом «чистой» математики, изучаемым
исключительно ради самого себя, теперь получило важные практические приложения в таких областях, как
криптография, электронная передача денежных сумм и хранение информации.

68

Глава 1. Построение абстракций с помощью процедур


занимает в 10 раз больше времени, чем для чисел, близких к 1000. Подтверждают ли это Ваши

замеры времени? Хорошо ли поддерживают предсказание n данные для 100 000 и 1 000 000?
Совместим ли Ваш результат с предположением, что программы на Вашей машине затрачивают
на выполнение задач время, пропорциональное числу шагов?
Упражнение 1.23.
Процедура smallest-divisor в начале этого раздела проводит множество лишних проверок:
после того, как она проверяет, делится ли число на 2, нет никакого смысла проверять делимость
на другие четные числа. Таким образом, вместо последовательности 2, 3, 4, 5, 6 . . . , используемой для test-divisor, было бы лучше использовать 2, 3, 5, 7, 9 . . . . Чтобы реализовать такое
улучшение, напишите процедуру next, которая имеет результатом 3, если получает 2 как аргумент, а иначе возвращает свой аргумент плюс 2. Используйте (next test-divisor) вместо (+
test-divisor 1) в smallest-divisor. Используя процедуру timed-prime-test с модифицированной версией smallest-divisor, запустите тест для каждого из 12 простых чисел,
найденных в упражнении 1.22. Поскольку эта модификация снижает количество шагов проверки
вдвое, Вы должны ожидать двукратного ускорения проверки. Подтверждаются ли эти ожидания?
Если нет, то каково наблюдаемое соотношение скоростей двух алгоритмов, и как Вы объясните
то, что оно отличается от 2?
Упражнение 1.24.
Модифицируйте процедуру timed-prime-test из упражнения 1.22 так, чтобы она использовала
fast-prime? (метод Ферма) и проверьте каждое из 12 простых чисел, найденных в этом упражнении. Исходя из того, что у теста Ферма порядок роста Θ(log n), то какого соотношения времени
Вы бы ожидали между проверкой на простоту поблизости от 1 000 000 и поблизости от 1000?
Подтверждают ли это Ваши данные? Можете ли Вы объяснить наблюдаемое несоответствие, если
оно есть?
Упражнение 1.25.
Лиза П. Хакер жалуется, что при написании expmod мы делаем много лишней работы. В конце
концов, говорит она, раз мы уже знаем, как вычислять степени, можно просто написать
(define (expmod base exp m)
(remainder (fast-expt base exp) m))
Права ли она? Стала бы эта процедура столь же хорошо работать при проверке простых чисел?
Объясните.
Упражнение 1.26.
У Хьюго Дума большие трудности в упражнении 1.24. Процедура fast-prime? у него работает
медленнее, чем prime?. Хьюго просит помощи у своей знакомой Евы Лу Атор. Вместе изучая
код Хьюго, они обнаруживают, что тот переписал процедуру expmod с явным использованием
умножения вместо того, чтобы вызывать square:
(define (expmod base exp m)
(cond ((= exp 0) 1)
((even? exp)
(remainder (* (expmod base (/ exp 2) m)
(expmod base (/ exp 2) m))
m))

1.3. Формулирование абстракций с помощью процедур высших порядков

69

(else
(remainder (* base (expmod base (- exp 1) m))
m))))
Хьюго говорит: «Я не вижу здесь никакой разницы». «Зато я вижу, — отвечает Ева. — Переписав процедуру таким образом, ты превратил процесс порядка Θ(log n) в процесс порядка Θ(n)».
Объясните.
Упражнение 1.27.
Покажите, что числа Кармайкла, перечисленные в сноске 47, действительно «обманывают» тест
Ферма: напишите процедуру, которая берет целое число n и проверяет, правда ли an равняется a
по модулю n для всех a < n, и проверьте эту процедуру на этих числах Кармайкла.
Упражнение 1.28.
Один из вариантов теста Ферма, который невозможно обмануть, называется тест Миллера–Рабина (Miller-Rabin test) (Miller 1976; Rabin 1980). Он основан наальтернативной формулировке
Малой теоремы Ферма, которая состоит в том, что если n — простое число, а a — произвольное
положительное целое число, меньшее n, то a в n − 1-ой степени равняется 1 по модулю n. Проверяя простоту числа n методом Миллера–Рабина, мы берем случайное число a < n и возводим
его в (n − 1)-ю степень по модулю n с помощью процедуры expmod. Однако когда в процедуре expmod мы проводим возведение в квадрат, мы проверяем, не нашли ли мы «нетривиальный
квадратный корень из 1 по модулю n», то есть число, не равное 1 или n − 1, квадрат которого
по модулю n равен 1. Можно доказать, что если такой нетривиальный квадратный корень из 1
существует, то n не простое число. Можно, кроме того, доказать, что если n — нечетное число, не
являющееся простым, то по крайней мере для половины чисел a < n вычисление an−1 с помощью
такой процедуры обнаружит нетривиальный квадратный корень из 1 по модулю n (вот почему
тест Миллера–Рабина невозможно обмануть). Модифицируйте процедуру expmod так, чтобы она
сигнализировала обнаружение нетривиального квадратного корня из 1, и используйте ее для реализации теста Миллера–Рабина с помощью процедуры, аналогичной fermat-test. Проверьте
свою процедуру на нескольких известных Вам простых и составных числах. Подсказка: удобный
способ заставить expmod подавать особый сигнал — заставить ее возвращать 0.

1.3. Формулирование абстракций с помощью процедур
высших порядков
Мы видели, что процедуры, в сущности, являются абстракциями, которые описывают составные операции над числами безотносительно к конкретным числам. Например,
когда мы определяем
(define (cube x) (* x x x))

мы говорим не о кубе какого-то конкретного числа, а о способе получить куб любого
числа. Разумеется, мы могли бы обойтись без определения этой процедуры, каждый раз
писать выражения вроде
(* 3 3 3)
(* x x x)
(* y y y)

70

Глава 1. Построение абстракций с помощью процедур

и никогда явно не упоминать понятие куба. Это поставило бы нас перед серьезным
затруднением и заставило бы работать только в терминах тех операций, которые оказались примитивами языка (в данном случае, в терминах умножения), а не в терминах
операций более высокого уровня. Наши программы были бы способны вычислять кубы,
однако в нашем языке не было бы возможности выразить идею возведения в куб. Одна
из тех вещей, которых мы должны требовать от мощного языка программирования — это
возможность строить абстракции путем присвоения имен общим схемам, а затем прямо
работать с этими абстракциями. Процедуры дают нам такую возможность. Вот почему
все языки программирования, кроме самых примитивных, обладают механизмами определения процедур.
Но даже при обработке численных данных наши возможности создавать абстракции окажутся сильно ограниченными, если мы сможем определять только процедуры,
параметры которых должны быть числами. Часто одна и та же схема программы используется с различными процедурами. Для того чтобы выразить эти схемы как понятия,
нам нужно строить процедуры, которые принимают другие процедуры как аргументы
либо возвращают их как значения. Процедура, манипулирующая другими процедурами, называется процедурой высшего порядка (higher-order procedure). В этом разделе
показывается, как процедуры высших порядков могут служить в качестве мощного механизма абстракции, резко повышая выразительную силу нашего языка.

1.3.1. Процедуры в качестве аргументов
Рассмотрим следующие три процедуры. Первая из них вычисляет сумму целых чисел
от a до b:
(define (sum-integers a b)
(if (> a b)
0
(+ a (sum-integers (+ a 1) b))))

Вторая вычисляет сумму кубов целых чисел в заданном диапазоне:
(define (sum-cubes a b)
(if (> a b)
0
(+ (cube a) (sum-cubes (+ a 1) b))))

Третья вычисляет сумму последовательности термов в ряде
1
1
1
+
+
+ ...
1 · 3 5 · 7 9 · 11
который (очень медленно) сходится к π/849 .
1
1
1
π
= 1 − + − + . . ., мы обя4
3
5
7
заны Лейбницу. В разделе 3.5.3 мы увидим, как использовать его как основу для некоторых изощренных
вычислительных трюков.
49

Этим рядом, который обычно записывают в эквивалентной форме

1.3. Формулирование абстракций с помощью процедур высших порядков

71

(define (pi-sum a b)
(if (> a b)
0
(+ (/ 1.0 (* a (+ a 2))) (pi-sum (+ a 4) b))))

Ясно, что за этими процедурами стоит одна общая схема. Большей частью они идентичны и различаются только именем процедуры, функцией, которая вычисляет терм,
подлежащий добавлению, и функцией, вычисляющей следующее значение a. Все эти
процедуры можно породить, заполнив дырки в одном шаблоне:
(define (hимяi a b)
(if (> a b)
0
(+ (hтермi a)
(hимяi (hследующийi a) b))))

Присутствие такого общего шаблона является веским доводом в пользу того, что
здесь скрыта полезная абстракция, которую только надо вытащить на поверхность. Действительно, математики давно выделили абстракцию суммирования последовательности (summation of a series) и изобрели «сигма-запись», например
b
X

f (n) = f (a) + . . . + f (b)

n=a

чтобы выразить это понятие. Сила сигма-записи состоит в том, что она позволяет математикам работать с самим понятием суммы, а не просто с конкретными суммами —
например, формулировать общие утверждения о суммах, независимые от конкретных
суммируемых последовательностей.
Подобным образом, мы как проектировщики программ хотели бы, чтобы наш язык
был достаточно мощным и позволял написать процедуру, которая выражала бы само
понятие суммы, а не только процедуры, вычисляющие конкретные суммы. В нашем
процедурном языке мы можем без труда это сделать, взяв приведенный выше шаблон и
преобразовав «дырки» в формальные параметры:
(define (sum term a next b)
(if (> a b)
0
(+ (term a)
(sum term (next a) next b))))

Заметьте, что sum принимает в качестве аргументов как нижнюю и верхнюю границы a и
b, так и процедуры term и next. Sum можно использовать так, как мы использовали бы
любую другую процедуру. Например, с ее помощью (вместе с процедурой inc, которая
увеличивает свой аргумент на 1), мы можем определить sum-cubes:
(define (inc n) (+ n 1))
(define (sum-cubes a b)
(sum cube a inc b))

Глава 1. Построение абстракций с помощью процедур

72

Воспользовавшись этим определением, мы можем вычислить сумму кубов чисел от 1 до
10:
(sum-cubes 1 10)
3025

С помощью процедуры идентичности (которая просто возвращает свой аргумент) для
вычисления терма, мы можем определить sum-integers через sum:
(define (identity x) x)
(define (sum-integers a b)
(sum identity a inc b))

Теперь можно сложить целые числа от 1 до 10:
(sum-integers 1 10)
55

Таким же образом определяется pi-sum50 :
(define (pi-sum a b)
(define (pi-term x)
(/ 1.0 (* x (+ x 2))))
(define (pi-next x)
(+ x 4))
(sum pi-term a pi-next b))

С помощью этих процедур мы можем вычислить приближение к π:
(* 8 (pi-sum 1 1000))
3.139592655589783

Теперь, когда у нас есть sum, ее можно использовать в качестве строительного блока
при формулировании других понятий. Например, определенный интеграл функции f
между пределами a и b можно численно оценить с помощью формулы
Z

a

b







 
dx
dx
dx
+ f a + dx +
+ f a + 2dx +
+ . . . dx
f = f a+
2
2
2

для малых значений dx. Мы можем прямо выразить это в виде процедуры:
(define (integral f a b dx)
(define (add-dx x) (+ x dx))
(* (sum f (+ a (/ dx 2)) add-dx b)
dx))
50 Обратите внимание, что мы использовали блочную структуру (раздел 1.1.8), чтобы спрятать определения
pi-next и pi-term внутри pi-sum, поскольку вряд ли эти процедуры понадобятся зачем-либо еще. В
разделе 1.3.2 мы совсем от них избавимся.

1.3. Формулирование абстракций с помощью процедур высших порядков

73

(integral cube 0 1 0.01)
.24998750000000042
(integral cube 0 1 0.001)
.249999875000001

(Точное значение интеграла cube от 0 до 1 равно 1/4.)
Упражнение 1.29.
Правило Симпсона — более точный метод численного интегрирования, чем представленный выше.
С помощью правила Симпсона интеграл функции f между a и b приближенно вычисляется в виде
h
[y0 + 4y1 + 2y2 + 4y3 + 2y4 + . . . + 2yn−2 + 4yn−1 + yn ]
3
где h = (b − a)/n, для какого-то четного целого числа n, а yk = f (a + kh). (Увеличение n повышает точность приближенного вычисления.) Определите процедуру, которая принимает в качестве
аргументов f , a, b и n, и возвращает значение интеграла, вычисленное по правилу Симпсона. С
помощью этой процедуры проинтегрируйте cube между 0 и 1 (с n = 100 и n = 1000) и сравните
результаты с процедурой integral, приведенной выше.
Упражнение 1.30.
Процедура sum порождает линейную рекурсию. Ее можно переписать так, чтобы суммирование
выполнялось итеративно. Покажите, как сделать это, заполнив пропущенные выражения в следующем определении:
(define (sum term a next b)
(define (iter a result)
(if h??i
h??i
(iter h??i h??i)))
(iter h??i h??i))
Упражнение 1.31.
а. Процедура sum — всего лишь простейшая из обширного множества подобных абстракций,
которые можно выразить через процедуры высших порядков.51. Напишите аналогичную процедуру
под названием product, которая вычисляет произведение значений функции в точках на указанном интервале. Покажите, как с помощью этой процедуры определить factorial. Кроме того,
при помощи product вычислите приближенное значение π по формуле52
π
2 · 4 · 4 · 6 · 6 · 8···
=
4
3 · 3 · 5 · 5 · 7 · 7···

51 Смысл упражнений 1.31–1.33 состоит в том, чтобы продемонстрировать выразительную мощь, получаемую, когда с помощью подходящей абстракции обобщается множество операций, казалось бы, не связанных
между собой. Однако, хотя накопление и фильтрация — изящные приемы, при их использовании руки у нас
пока что несколько связаны, поскольку пока что у нас нет структур данных, которые дают подходящие к этим
абстракциям средства комбинирования. В разделе 2.2.3 мы вернемся к этим приемам и покажем, как использовать последовательности (sequences) в качестве интерфейсов для комбинирования фильтров и накопителей,
так что получаются еще более мощные абстракции. Мы увидим, как эти методы сами по себе становятся
мощным и изящным подходом к проектированию программ.
52 Эту формулу открыл английский математик семнадцатого века Джон Уоллис.

74

Глава 1. Построение абстракций с помощью процедур

б. Если Ваша процедура product порождает рекурсивный процесс, перепишите ее так, чтобы
она порождала итеративный. Если она порождает итеративный процесс, перепишите ее так, чтобы
она порождала рекурсивный.
Упражнение 1.32.
а. Покажите, что sum и product (упражнение 1.31) являются частными случаями еще более
общего понятия, называемого накопление (accumulation), которое комбинирует множество термов с помощью некоторой общей функции накопления
(accumulate combiner null-value term a next b)
Accumulate принимает в качестве аргументов те же описания термов и диапазона, что и sum с
product, а еще процедуру combiner (двух аргументов), которая указывает, как нужно присоединить текущий терм к результату накопления предыдущих, и null-value, базовое значение,
которое нужно использовать, когда термы закончатся. Напишите accumulate и покажите, как и
sum, и product можно определить в виде простых вызовов accumulate.
б. Если Ваша процедура accumulate порождает рекурсивный процесс, перепишите ее так,
чтобы она порождала итеративный. Если она порождает итеративный процесс, перепишите ее так,
чтобы она порождала рекурсивный.
Упражнение 1.33.
Можно получить еще более общую версию accumulate (упражнение 1.32), если ввести понятие
фильтра (filter) на комбинируемые термы. То есть комбинировать только те термы, порожденные
из значений диапазона, которые удовлетворяют указанному условию. Получающаяся абстракция
filtered-accumulate получает те же аргументы, что и accumulate, плюс дополнительный
одноаргументный предикат, который определяет фильтр. Запишите filtered-accumulate в
виде процедуры. Покажите, как с помощью filtered-accumulate выразить следующее:
а. сумму квадратов простых чисел в интервале от a до b (в предположении, что процедура
prime? уже написана);
б. произведение всех положительных целых чисел меньше n, которые просты по отношению к
n (то есть всех таких положительных целых чисел i < n, что НОД(i, n) = 1).

1.3.2. Построение процедур с помощью lambda
Когда в разделе 1.3.1 мы использовали sum, очень неудобно было определять тривиальные процедуры вроде pi-term и pi-next только ради того, чтобы передать их как
аргументы в процедуры высшего порядка. Было бы проще вместо того, чтобы вводить
имена pi-next и pi-term, прямо определить «процедуру, которая возвращает свой
аргумент плюс 4» и «процедуру, которая вычисляет число, обратное произведению аргумента и аргумента плюс 2». Это можно сделать, введя особую форму lambda, которая
создает процедуры. С использованием lambda мы можем записать требуемое в таком
виде:
(lambda (x) (+ x 4))

и
(lambda (x) (/ 1.0 (* x (+ x 2))))

Тогда нашу процедуру pi-sum можно выразить безо всяких вспомогательных процедур:

1.3. Формулирование абстракций с помощью процедур высших порядков

75

(define (pi-sum a b)
(sum (lambda (x) (/ 1.0 (* x (+ x 2))))
a
(lambda (x) (+ x 4))
b))

Еще с помощью lambda мы можем записать процедуру integral, не определяя
вспомогательную процедуру add-dx:
(define (integral f a b dx)
(* (sum f
(+ a (/ dx 2.0))
(lambda (x) (+ x dx))
b)
dx))

В общем случае, lambda используется для создания процедур точно так же, как
define, только никакого имени для процедуры не указывается:
(lambda (hформальные-параметрыi) hтелоi)

Получается столь же полноценная процедура, как и с помощью define. Единственная
разница состоит в том, что она не связана ни с каким именем в окружении. На самом
деле
(define (plus4 x) (+ x 4))

эквивалентно
(define plus4 (lambda (x) (+ x 4)))

Можно читать выражение lambda так:
(lambda

Процедура

(x)

от аргумента x,

которая

(+

складывает

x

x

и

4))

4

Подобно любому выражению, значением которого является процедура, выражение с
lambda можно использовать как оператор в комбинации, например
((lambda (x y z) (+ x y (square z))) 1 2 3)
12

Или, в более общем случае, в любом контексте, где обычно используется имя процедуры53 .
53 Было бы более понятно и менее страшно для изучающих Лисп, если бы здесь использовалось более
ясное имя, чем lambda, например make-procedure. Однако традиция уже прочно укоренилась. Эта нотация
заимствована из λ-исчисления, формализма, изобретенного математическим логиком Алонсо Чёрчем (Church
1941). Чёрч разработал λ-исчисление, чтобы найти строгое основание для понятий функции и применения
функции. λ-исчисление стало основным инструментом математических исследований по семантике языков
программирования.

Глава 1. Построение абстракций с помощью процедур

76

Создание локальных переменных с помощью let
Еще одно применение lambda состоит во введении локальных переменных. Часто нам в процедуре бывают нужны локальные переменные помимо тех, что связаны
формальными параметрами. Допустим, например, что нам надо вычислить функцию
f (x, y) = x(1 + xy)3 + y(1 − y) + (1 + xy)(1 − y)
которую мы также могли бы выразить как
a =
b =
f (x, y) =

1 + xy
1−y
xa2 + yb + ab

Когда мы пишем процедуру для вычисления f , хотелось бы иметь как локальные переменные не только x и y, но и имена для промежуточных результатов вроде a и b. Можно
сделать это с помощью вспомогательной процедуры, которая связывает локальные переменные:
(define (f x y)
(define (f-helper a b)
(+ (* x (square a))
(* y b)
(* a b)))
(f-helper (+ 1 (* x y))
(- 1 y)))

Разумеется, безымянную процедуру для связывания локальных переменных мы можем записать через lambda-выражение. При этом тело f оказывается просто вызовом
этой процедуры.
(define (f x y)
((lambda (a b)
(+ (* x (square a))
(* y b)
(* a b)))
(+ 1 (* x y))
(- 1 y)))

Такая конструкция настолько полезна, что есть особая форма под названием let, которая делает ее более удобной. С использованием let процедуру f можно записать
так:
(define (f x y)
(let ((a (+ 1 (* x y)))
(b (- 1 y)))
(+ (* x (square a))
(* y b)
(* a b))))

Общая форма выражения с let такова:

1.3. Формулирование абстракций с помощью процедур высших порядков

77

(let ((hпер1 i hвыр1 i)
(hпер2 i hвыр2 i)
...
(hперn i hвырn i))
hтелоi)

Это можно понимать как
Пусть hпер1 i имеет значение hвыр1 i
и hпер2 i имеет значение hвыр2 i
...
и hперn i имеет значение hвырn i
в hтелеi
Первая часть let-выражения представляет собой список пар вида имя–значение. Когда
let вычисляется, каждое имя связывается со значением соответствующего выражения.
Затем вычисляется тело let, причем эти имена связаны как локальные переменные.
Происходит это так: выражение let интерпретируется как альтернативная форма для
((lambda (hпер1 i ... hперn i)
hтелоi)
hвыр1 i ... hвырn i)

От интерпретатора не требуется никакого нового механизма связывания переменных.
Выражение с let — это всего лишь синтаксический сахар для вызова lambda.
Из этой эквивалентности мы видим, что область определения переменной, введенной в let-выражении — тело let. Отсюда следует, что:
• Let позволяет связывать переменные сколь угодно близко к тому месту, где они
используются. Например, если значение x равно 5, значение выражения
(+ (let ((x 3))
(+ x (* x 10)))
x)

равно 38. Значение x в теле let равно 3, так что значение let-выражения равно 33. С
другой стороны, x как второй аргумент к внешнему + по-прежнему равен 5.
• Значения переменных вычисляются за пределами let. Это существенно, когда
выражения, дающие значения локальным переменным, зависят от переменных, которые
имеют те же имена, что и сами локальные переменные. Например, если значение x равно
2, выражение
(let ((x 3)
(y (+ x 2)))
(* x y))

будет иметь значение 12, поскольку внутри тела let x будет равно 3, а y 4 (что равняется внешнему x плюс 2).

78

Глава 1. Построение абстракций с помощью процедур

Иногда с тем же успехом, что и let, можно использовать внутренние определения.
Например, вышеописанную процедуру f мы могли бы определить как
(define (f x y)
(define a (+ 1 (* x y)))
(define b (- 1 y))
(+ (* x (square a))
(* y b)
(* a b)))

В таких ситуациях, однако, мы предпочитаем использовать let, а define писать только
при определении локальных процедур54 .
Упражнение 1.34.
Допустим, мы определили процедуру
(define (f g)
(g 2))
Тогда мы имеем
(f square)
4
(f (lambda (z) (* z (+ z 1))))
6
Что случится, если мы (извращенно) попросим интерпретатор вычислить комбинацию (f f)?
Объясните.

1.3.3. Процедуры как обобщенные методы
Мы ввели составные процедуры в разделе 1.1.4 в качестве механизма для абстракции
схем числовых операций, так, чтобы они были независимы от конкретных используемых
чисел. С процедурами высших порядков, такими, как процедура integral из раздела 1.3.1, мы начали исследовать более мощный тип абстракции: процедуры, которые используются для выражения обобщенных методов вычисления, независимо от конкретных
используемых функций. В этом разделе мы рассмотрим два более подробных примера —
общие методы нахождения нулей и неподвижных точек функций, — и покажем, как эти
методы могут быть прямо выражены в виде процедур.
Нахождение корней уравнений методом половинного деления
Метод половинного деления (half-interval method) — это простой, но мощный способ
нахождения корней уравнения f (x) = 0, где f — непрерывная функция. Идея состоит в
том, что если нам даны такие точки a и b, что f (a) < 0 < f (b), то функция f должна
54 Если мы хотим понимать внутренние определения настолько, чтобы быть уверенными, что программа
действительно соответствует нашим намерениям, то нам требуется более сложная модель процесса вычислений,
чем приведенная в этой главе. Однако с внутренними определениями процедур эти тонкости не возникают. К
этому вопросу мы вернемся в разделе 4.1.6, после того, как больше узнаем о вычислении.

1.3. Формулирование абстракций с помощью процедур высших порядков

79

иметь по крайней мере один ноль на отрезке между a и b. Чтобы найти его, возьмем x,
равное среднему между a и b, и вычислим f (x). Если f (x) > 0, то f должна иметь ноль
на отрезке между a и x. Если f (x) < 0, то f должна иметь ноль на отрезке между x и b.
Продолжая таким образом, мы сможем находить все более узкие интервалы, на которых
f должна иметь ноль. Когда мы дойдем до точки, где этот интервал достаточно мал,
процесс останавливается. Поскольку интервал неопределенности уменьшается вдвое на
каждом шаге процесса, число требуемых шагов растет как Θ(log(L/T )), где L есть длина
исходного интервала, а T есть допуск ошибки (то есть размер интервала, который мы
считаем «достаточно малым»). Вот процедура, которая реализует эту стратегию:
(define (search f neg-point pos-point)
(let ((midpoint (average neg-point pos-point)))
(if (close-enough? neg-point pos-point)
midpoint
(let ((test-value (f midpoint)))
(cond ((positive? test-value)
(search f neg-point midpoint))
((negative? test-value)
(search f midpoint pos-point))
(else midpoint))))))

Мы предполагаем, что вначале нам дается функция f и две точки, в одной из которых значение функции отрицательно, в другой положительно. Сначала мы вычисляем
среднее между двумя краями интервала. Затем мы проверяем, не является ли интервал
уже достаточно малым, и если да, сразу возвращаем среднюю точку как ответ. Если
нет, мы вычисляем значение f в средней точке. Если это значение положительно, мы
продолжаем процесс с интервалом от исходной отрицательной точки до средней точки.
Если значение в средней точке отрицательно, мы продолжаем процесс с интервалом от
средней точки до исходной положительной точки. Наконец, существует возможность,
что значение в средней точке в точности равно 0, и тогда средняя точка и есть тот
корень, который мы ищем.
Чтобы проверить, достаточно ли близки концы интервала, мы можем взять процедуру, подобную той, которая используется в разделе 1.1.7 при вычислении квадратных
корней55:
(define (close-enough? x y)
(< (abs (- x y)) 0.001))

Использовать процедуру search непосредственно ужасно неудобно, поскольку случайно мы можем дать ей точки, в которых значения f не имеют нужных знаков, и в этом
случае мы получим неправильный ответ. Вместо этого мы будем использовать search
посредством следующей процедуры, которая проверяет, который конец интервала имеет
положительное, а который отрицательное значение, и соответствующим образом зовет
55 Мы использовали 0.001 как достаточно «малое» число, чтобы указать допустимую ошибку вычисления.
Подходящий допуск в настоящих вычислениях зависит от решаемой задачи, ограничений компьютера и алгоритма. Часто это весьма тонкий вопрос, в котором требуется помощь специалиста по численному анализу или
волшебника какого-нибудь другого рода.

Глава 1. Построение абстракций с помощью процедур

80

search. Если на обоих концах интервала функция имеет одинаковый знак, метод половинного деления использовать нельзя, и тогда процедура сообщает об ошибке56 :
(define (half-interval-method f a b)
(let ((a-value (f a))
(b-value (f b)))
(cond ((and (negative? a-value) (positive? b-value))
(search f a b))
((and (negative? b-value) (positive? a-value))
(search f b a))
(else
(error "У аргументов не разные знаки " a b)))))

В следующем примере метод половинного деления используется, чтобы вычислить π
как корень уравнения sin x = 0, лежащий между 2 и 4.
(half-interval-method sin 2.0 4.0)
3.14111328125

Во втором примере через метод половинного деления ищется корень уравнения x3 −
2x − 3 = 0, расположенный между 1 и 2:
(half-interval-method (lambda (x) (- (* x x x) (* 2 x) 3))
1.0
2.0)
1.89306640625

Нахождение неподвижных точек функций
Число x называется неподвижной точкой (fixed point) функции f , если оно удовлетворяет уравнению f (x) = x. Для некоторых функций f можно найти неподвижную
точку, начав с какого-то значения и применяя f многократно:
f (x), f (f (x)), f (f (f (x))), . . .
— пока значение не перестанет сильно изменяться. С помощью этой идеи мы можем составить процедуру fixed-point, которая в качестве аргументов принимает функцию и
начальное значение и производит приближение к неподвижной точке функции. Мы многократно применяем функцию, пока не найдем два последовательных значения, разница
между которыми меньше некоторой заданной чувствительности:
(define tolerance 0.00001)
(define (fixed-point f first-guess)
(define (close-enough? v1 v2)
(< (abs (- v1 v2)) tolerance))
(define (try guess)
56 Этого можно добиться с помощью процедуры error, которая в качестве аргументов принимает несколько
значений и печатает их как сообщение об ошибке.

1.3. Формулирование абстракций с помощью процедур высших порядков

81

(let ((next (f guess)))
(if (close-enough? guess next)
next
(try next))))
(try first-guess))

Например, с помощью этого метода мы можем приближенно вычислить неподвижную
точку функции косинус, начиная с 1 как стартового приближения57:
(fixed-point cos 1.0)
.7390822985224023

Подобным образом можно найти решение уравнения y = siny + cosy:
(fixed-point (lambda (y) (+ (sin y) (cos y)))
1.0)
.2587315962971173

Процесс поиска неподвижной точки похож на процесс, с помощью которого мы искали квадратный корень в разделе 1.1.7. И тот, и другой основаны на идее последовательного улучшения приближений, пока результат не удовлетворит какому-то критерию.
На самом деле мы без труда можем сформулироватьвычисление квадратного корня как
поиск неподвижной точки. Вычислить квадратного корня из произвольного числа x означает найти такое y, что y 2 = x. Переведя это уравнение в эквивалентную форму y = x/y,
мы обнаруживаем, что должны найти неподвижную точку функции58 y 7→ x/y, и, следовательно, мы можем попытаться вычислять квадратные корни так:
(define (sqrt x)
(fixed-point (lambda (y) (/ x y))
1.0))

К сожалению, этот поиск неподвижной точки не сходится. Рассмотрим исходное значение y1 . Следующее значение равно y2 = x/y1 , а следующее за ним y3 = x/y2 =
x/(x/y1 ) = y1 . В результате выходит бесконечный цикл, в котором два значения y1 и y2
повторяются снова и снова, прыгая вокруг правильного ответа.
Один из способов управлять такими прыжками состоит в том, чтобы заставить значения изменяться не так сильно. Поскольку ответ всегда находится между текущим
значением y и x/y, мы можем взять новое значение, не настолько далекое от y, как x/y,
1
взяв среднее между ними, так что следующее значение будет не x/y, а (y + x/y). Про2
цесс получения такой последовательности есть всего лишь процесс поиска неподвижной
1
точки y 7→ (y + x/y).
2
(define (sqrt x)
(fixed-point (lambda (y) (average y (/ x y)))
1.0))
57 Попробуйте во время скучной лекции установить калькулятор в режим радиан и нажимать кнопку cos,
пока не найдете неподвижную точку.
58 7→ (произносится «отображается в») — это математический способ написать lambda. y 7→ x/y означает
(lambda (y) (/ x y)), то есть функцию, значение которой в точке y есть x/y.

82

Глава 1. Построение абстракций с помощью процедур

1
(y + x/y) всего лишь простая трансформация уравнения y = x/y;
2
чтобы ее получить, добавьте y к обоим частям уравнения и поделите пополам.)
После такой модификации процедура поиска квадратного корня начинает работать.
В сущности, если мы рассмотрим определения, мы увидим, что последовательность приближений к квадратному корню, порождаемая здесь, в точности та же, что порождается
нашей исходной процедурой поиска квадратного корня из раздела 1.1.7. Этот подход с
усреднением последовательных приближений к решению, метод, который мы называем
торможение усреднением (average damping), часто помогает достичь сходимости при
поисках неподвижной точки.

(Заметим, что y =

Упражнение 1.35.
Покажите, что золотое сечение φ (раздел 1.2.2) есть неподвижная точка трансформации x 7→
1 + 1/x, и используйте этот факт для вычисления φ с помощью процедуры fixed-point.
Упражнение 1.36.
Измените процедуру fixed-point так, чтобы она печатала последовательность приближений,
которые порождает, с помощью примитивов newline и display, показанных в упражнении 1.22. Затем найдите решение уравнения xx = 1000 путем поиска неподвижной точки
x 7→ log(1000)/ log(x). (Используйте встроенную процедуру Scheme log, которая вычисляет натуральные логарифмы.) Посчитайте, сколько шагов это занимает при использовании торможения
усреднением и без него. (Учтите, что нельзя начинать fixed-point со значения 1, поскольку это
вызовет деление на log(1) = 0.)
Упражнение 1.37.
а. Бесконечнаяцепная дробь (continued fraction) есть выражение вида
N1

f=

N2

D1 +
D2 +

N3
D3 + . . .

В качестве примера можно показать, что расширение бесконечной цепной дроби при всех Ni и
Di , равных 1, дает 1/φ, где φ — золотое сечение (описанное в разделе 1.2.2). Один из способов
вычислить цепную дробь состоит в том, чтобы после заданного количества термов оборвать вычисление. Такой обрыв — так называемая конечная цепная дробь (finite continued fraction) из k
элементов, — имеет вид
N1
f=
N2
D1 +
Nk
D2 + . . .
Dk
Предположим, что n и d — процедуры одного аргумента (номера элемента i), возвращающие Ni и
Di элементов цепной дроби. Определите процедуру cont-frac так, чтобы вычисление (contfrac n d k) давало значение k-элементной конечной цепной дроби. Проверьте свою процедуру,
вычисляя приближения к 1/φ с помощью
(cont-frac (lambda (i) 1.0)
(lambda (i) 1.0)
k)

1.3. Формулирование абстракций с помощью процедур высших порядков

83

для последовательных значений k. Насколько большим пришлось сделать k, чтобы получить приближение, верное с точностью 4 цифры после запятой?
б. Если Ваша процедура cont-frac порождает рекурсивный процесс, напишите вариант, который порождает итеративный процесс. Если она порождает итеративный процесс, напишите вариант, порождающий рекурсивный процесс.
Упражнение 1.38.
В 1737 году швейцарский математик Леонард Эйлер опубликовал статью De functionibus
Continuis, которая содержала расширение цепной дроби для e − 2, где e — основание натуральных
логарифмов. В этой дроби все Ni равны 1, а Di последовательно равны 1, 2, 1, 1, 4, 1, 1, 6, 1, 1, 8, . . .Напишите
программу, использующую Вашу процедуру cont-frac из упражнения 1.37 для вычисления e на
основании формулы Эйлера.
Упражнение 1.39.
Представление тангенса в виде цепной дроби было опубликовано в 1770 году немецким математиком Й.Х. Ламбертом:
x
tg x =
x2
1−
x2
3−
5 − ...
где x дан в радианах. Определите процедуру (tan-cf x k), которая вычисляет приближение к
тангенсу на основе формулы Ламберта. K указывает количество термов, которые требуется вычислить, как в упражнении 1.37.

1.3.4. Процедуры как возвращаемые значения
Предыдущие примеры показывают, что возможность передавать процедуры в качестве аргументов значительно увеличивает выразительную силу нашего языка программирования. Мы можем добиться еще большей выразительной силы, создавая процедуры,
возвращаемые значения которых сами являются процедурами.
Эту идею можно проиллюстрировать примером с поиском неподвижной точки, обсуждаемым в конце раздела 1.3.3. Мы сформулировали новую версию процедуры вычис√
ления квадратного корня как поиск неподвижной точки, начав с наблюдения, что x
есть неподвижная точка функции y 7→ x/y. Затем мы использовали торможение усреднением, чтобы заставить приближения сходиться. Торможение усреднением само по себе
является полезным приемом. А именно, получив функцию f , мы возвращаем функцию,
значение которой в точке х есть среднее арифметическое между x и f (x).
Идею торможения усреднением мы можем выразить при помощи следующей процедуры:
(define (average-damp f)
(lambda (x) (average x (f x))))

Average-damp — это процедура, принимающая в качестве аргумента процедуру f и
возвращающая в качестве значения процедуру (полученную с помощью lambda), которая, будучи применена к числу x, возвращает среднее между x и (f x). Например,

84

Глава 1. Построение абстракций с помощью процедур

применение average-damp к процедуре square получает процедуру, значением которой для некоторого числа x будет среднее между x и x2 . Применение этой процедуры к
числу 10 возвращает среднее между 10 и 100, то есть 5559 :
((average-damp square) 10)
55

Используя average-damp, мы можем переформулировать процедуру вычисления
квадратного корня следующим образом:
(define (sqrt x)
(fixed-point (average-damp (lambda (y) (/ x y)))
1.0))

Обратите внимание, как такая формулировка делает явными три идеи нашего метода: поиск неподвижной точки, торможение усреднением и функцию y 7→ x/y. Полезно
сравнить такую формулировку метода поиска квадратного корня с исходной версией,
представленной в разделе 1.1.7. Вспомните, что обе процедуры выражают один и тот
же процесс, и посмотрите, насколько яснее становится его идея, когда мы выражаем
процесс в терминах этих абстракций. В общем случае существует много способов сформулировать процесс в виде процедуры. Опытные программисты знают, как выбрать те
формулировки процедур, которые наиболее ясно выражают их мысли, и где полезные
элементы процесса показаны в виде отдельных сущностей, которые можно использовать
в других приложениях. Чтобы привести простой пример такого нового использования,
заметим, что кубический корень x является неподвижной точкой функции y 7→ x/y 2 ,
так что мы можем немедленно обобщить нашу процедуру поиска квадратного корня так,
чтобы она извлекала кубические корни60 :
(define (cube-root x)
(fixed-point (average-damp (lambda (y) (/ x (square y))))
1.0))

Метод Ньютона
Когда в разделе 1.1.7 мы впервые представили процедуру извлечения квадратного корня, мы упомянули, что это лишь частный случайметода Ньютона (Newton’s
method). Если x 7→ g(x) есть дифференцируемая функция, то решение уравнения
g(x) = 0 есть неподвижная точка функции x 7→ f (x), где
f (x) = x −

g(x)
Dg(x)

а Dg(x) есть производная g, вычисленная в точке x. Метод Ньютона состоит в том, чтобы
применить описанный способ поиска неподвижной точки и аппроксимировать решение
59 Заметьте, что здесь мы имеем комбинацию, оператор которой сам по себе комбинация. В упражнении 1.4
уже была продемонстрирована возможность таких комбинаций, но то был всего лишь игрушечный пример.
Здесь мы начинаем чувствовать настоящую потребность в выражениях такого рода — когда нам нужно применить процедуру, полученную в качестве значения из процедуры высшего порядка.
60 См. дальнейшее обобщение в упражнении 1.45

1.3. Формулирование абстракций с помощью процедур высших порядков

85

уравнения путем поиска неподвижной точки функции f .61 Для многих функций g при
достаточно хорошем начальном значении x метод Ньютона очень быстро приводит к
решению уравнения g(x) = 062 .
Чтобы реализовать метод Ньютона в виде процедуры, сначала нужно выразить понятие производной. Заметим, что «взятие производной», подобно торможению усреднением,
трансформирует одну функцию в другую. Например, производная функции x 7→ x3 есть
функция x 7→ 3x2 . В общем случае, если g есть функция, а dx — маленькое число, то
производная Dg функции g есть функция, значение которой в каждой точке x описывается формулой (при dx, стремящемся к нулю)
Dg(x) =

g(x + dx) − g(x)
dx

Таким образом, мы можем выразить понятие производной (взяв dx равным, например,
0.00001) в виде процедуры
(define (deriv g)
(lambda (x)
(/ (- (g (+ x dx)) (g x))
dx)))

дополненной определением
(define dx 0.00001)

Подобно average-damp, deriv является процедурой, которая берет процедуру в
качестве аргумента и возвращает процедуру как значение. Например, чтобы найти приближенное значение производной x 7→ x3 в точке 5 (точное значение производной равно
75), можно вычислить
(define (cube x) (* x x x))
((deriv cube) 5)
75.00014999664018

С помощью deriv мы можем выразить метод Ньютона как процесс поиска неподвижной точки:
(define (newton-transform g)
(lambda (x)
(- x (/ (g x) ((deriv g) x)))))
(define (newtons-method g guess)
(fixed-point (newton-transform g) guess))
61 Вводные курсы анализа обычно описывают метод Ньютона через последовательность приближений x
n+1 =
xn − g(xn )/Dg(xn ). Наличие языка, на котором мы можем говорить о процессах, а также использование идеи
неподвижных точек, упрощают описание этого метода.
62 Метод Ньютона не всегда приводит к решению, но можно показать, что в удачных случаях каждая итерация удваивает точность приближения в терминах количества цифр после запятой. Для таких случаев метод
Ньютона сходится гораздо быстрее, чем метод половинного деления.

86

Глава 1. Построение абстракций с помощью процедур

Процедура newton-transform выражает формулу, приведенную в начале этого раздела, а newtons-method легко определяется с ее помощью. В качестве аргументов
она принимает процедуру, вычисляющую функцию, чей ноль мы хотим найти, а также
начальное значение приближения. Например, чтобы найти квадратный корень x, мы можем с помощью метода Ньютона найти ноль функции y 7→ y 2 − x, начиная со значения
163 . Это дает нам еще одну форму процедуры вычисления квадратного корня:
(define (sqrt x)
(newtons-method (lambda (y) (- (square y) x))
1.0))

Абстракции и процедуры как полноправные объекты
Мы видели два способа представить вычисление квадратного корня как частный случай более общего метода; один раз это был поиск неподвижной точки, другой — метод
Ньютона. Поскольку сам метод Ньютона был выражен как процесс поиска неподвижной точки, на самом деле мы увидели два способа вычислить квадратный корень как
неподвижную точку. Каждый из этих методов получает некоторую функцию и находит
неподвижную точку для некоторой трансформации этой функции. Эту общую идею мы
можем выразить как процедуру:
(define (fixed-point-of-transform g transform guess)
(fixed-point (transform g) guess))

Эта очень общая процедура принимает в качестве аргументов процедуру g, которая вычисляет некоторую функцию, процедуру, которая трансформирует g, и начальное приближение. Возвращаемое значение есть неподвижная точка трансформированной функции.
С помощью такой абстракции можно переформулировать процедуру вычисления квадратного корня из этого раздела (ту, где мы ищем неподвижную точку версии y 7→ x/y,
заторможенной усреднением) как частный случай общего метода:
(define (sqrt x)
(fixed-point-of-transform (lambda (y) (/ x y))
average-damp
1.0))

Подобным образом, вторую процедуру нахождения квадратного корня из этого раздела
(пример применения метода Ньютона, который находит неподвижную точку Ньютонова
преобразования y 7→ y 2 − x) можно представить так:
(define (sqrt x)
(fixed-point-of-transform (lambda (y) (- (square y) x))
newton-transform
1.0))

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

1.3. Формулирование абстракций с помощью процедур высших порядков

87

процедуры высших порядков позволяют нам манипулировать этими общими методами и
создавать еще более глубокие абстракции.
Как программисты, мы должны быть готовы распознавать возможности поиска абстракций, лежащих в основе наших программ, строить нашу работу на таких абстракциях и обобщать их, создавая еще более мощные абстракции. Это не значит, что программы всегда нужно писать на возможно более глубоком уровне абстракции: опытные
программисты умеют выбирать тот уровень, который лучше всего подходит к их задаче. Однако важно быть готовыми мыслить в терминах этих абстракций и быть готовым
применить их в новых контекстах. Важность процедур высшего порядка состоит в том,
что они позволяют нам явно представлять эти абстракции в качестве элементов нашего
языка программирования, так что мы можем обращаться с ними так же, как и с другими
элементами вычисления.
В общем случае языки программирования накладывают ограничения на способы, с
помощью которых можно манипулировать элементами вычисления. Говорят, что элементы, на которые накладывается наименьшее число ограничений, имеют статус элементов
вычисления первого класса (first-class) или полноправных. Вот некоторые из их «прав и
привилегий»64:
• Их можно называть с помощью переменных.

• Их можно передавать в процедуры в качестве аргументов.
• Их можно возвращать из процедур в виде результата.
• Их можно включать в структуры данных65 .

Лисп, в отличие от других распространенных языков программирования, дает процедурам полноправный статус. Это может быть проблемой для эффективной реализации,
но зато получаемый выигрыш в выразительной силе огромен66 .
Упражнение 1.40.
Определите процедуру cubic, которую можно было бы использовать совместно с процедурой
newtons-method в выражениях вида
(newtons-method (cubic a b c) 1)
для приближенного вычисления нулей кубических уравнений x3 + ax2 + bx + c.
Упражнение 1.41.
Определите процедуру double, которая принимает как аргумент процедуру с одним аргументом и
возвращает процедуру, которая применяет исходную процедуру дважды. Например, если процедура inc добавляет к своему аргументу 1, то (double inc) должна быть процедурой, которая
добавляет 2. Скажите, какое значение возвращает
(((double (double double)) inc) 5)
64 Понятием полноправного статуса элементов языка программирования мы обязаны британскому специалисту по информатике Кристоферу Стрейчи (1916-1975).
65 Примеры этого мы увидим после того, как введем понятие структур данных в главе 2.
66 Основная цена, которую реализации приходится платить за придание процедурам статуса полноправных
объектов, состоит в том, что, поскольку мы разрешаем возвращать процедуры как значения, нам нужно оставлять память для хранения свободных переменных процедуры даже тогда, когда она не выполняется. В реализации Scheme, которую мы рассмотрим в разделе 4.1, эти переменные хранятся в окружении процедуры.

Глава 1. Построение абстракций с помощью процедур

88

Упражнение 1.42.
Пусть f и g — две одноаргументные функции. По определению, композиция (composition) f и
g есть функция x 7→ f (g(x)). Определите процедуру compose которая реализует композицию.
Например, если inc — процедура, добавляющая к своему аргументу 1,
((compose square inc) 6)
49

Упражнение 1.43.
Если f есть численная функция, а n — положительное целое число, то мы можем построить
n-кратное применение f , которое определяется как функция, значение которой в точке x равно
f (f (. . . (f (x)) . . .)). Например, если f есть функция x 7→ x + 1, то n-кратным применением f
будет функция x 7→ x + n. Если f есть операция возведения в квадрат, то n-кратное применение
f есть функция, которая возводит свой аргумент в 2n -ю степень. Напишите процедуру, которая
принимает в качестве ввода процедуру, вычисляющую f , и положительное целое n, и возвращает
процедуру, вычисляющую n-кратное применение f . Требуется, чтобы Вашу процедуру можно было
использовать в таких контекстах:
((repeated square 2) 5)
625
Подсказка: может оказаться удобно использовать compose из упражнения 1.42.
Упражнение 1.44.
Идея сглаживания (smoothing a function) играет важную роль в обработке сигналов. Если f —
функция, а dx — некоторое малое число, то сглаженная версия f есть функция, значение которой в точке x есть среднее между f (x − dx), f (x) и f (x + dx). Напишите процедуру smooth,
которая в качестве ввода принимает процедуру, вычисляющую f , и возвращает процедуру, вычисляющую сглаженную версию f . Иногда бывает удобно проводить повторное сглаживание (то
есть сглаживать сглаженную функцию и т.д.), получая n-кратно сглаженную функцию (n-fold
smoothed function). Покажите, как породить n-кратно сглаженную функцию с помощью smooth и
repeated из упражнения 1.43.
Упражнение 1.45.
В разделе 1.3.3 мы видели, что попытка вычисления квадратных корней путем наивного поиска неподвижной точки y 7→ x/y не сходится, и что это можно исправить путем торможения
усреднением. Тот же самый метод работает для нахождения кубического корня как неподвижной
точки y 7→ x/y 2 , заторможенной усреднением. К сожалению, этот процесс не работает для корней четвертой степени — однажды примененного торможения усреднением недостаточно, чтобы
заставить сходиться процесс поиска неподвижной точки y 7→ x/y 3 . С другой стороны, если мы
применим торможение усреднением дважды (т.е. применим торможение усреднением к результату
торможения усреднением от y 7→ x/y 3 ), то поиск неподвижной точки начнет сходиться. Проделайте эксперименты, чтобы понять, сколько торможений усреднением нужно, чтобы вычислить
корень n-ой степени как неподвижную точку на основе многократного торможения усреднением
функции y 7→ x/y n−1 . Используя свои результаты для того, напишите простую процедуру вычислениякорней n-ой степени с помощью процедур fixed-point, average-damp и repeated из
упражнения 1.43. Считайте, что все арифметические операции, какие Вам понадобятся, присутствуют в языке как примитивы.

1.3. Формулирование абстракций с помощью процедур высших порядков

89

Упражнение 1.46.
Некоторые из вычислительных методов, описанных в этой главе, являются примерами чрезвычайно
общей вычислительной стратегии, называемой пошаговое улучшение (iterative improvement). Пошаговое улучшение состоит в следующем: чтобы что-то вычислить, нужно взять какое-то начальное значение, проверить, достаточно ли оно хорошо, чтобы служить ответом, и если нет, то улучшить это значение и продолжить процесс с новым значением. Напишите процедуру iterativeimprove, которая принимает в качестве аргументов две процедуры: проверку, достаточно ли хорошо значение, и метод улучшения значения. Iterative-improve должна возвращать процедуру,
которая принимает начальное значение в качестве аргумента и улучшает его, пока оно не станет
достаточно хорошим. Перепишите процедуру sqrt из раздела 1.1.7 и процедуру fixed-point
из раздела 1.3.3 в терминах iterative-improve.

ГЛАВА 2
ПОСТРОЕНИЕ

АБСТРАКЦИЙ С ПОМОЩЬЮ
ДАННЫХ
Теперь мы подходим к решающему шагу
в математической абстракции: мы
забываем, что обозначают наши символы.
...[Математик] не должен стоять на
месте: есть много операций, которые он
может производить с этими символами,
не обращая внимания на те вещи,
которые они обозначают.
Герман Вейль.
«Математический способ мышления»

В главе 1 мы сконцентрировали внимание на вычислительных процессах и роли
процедур в проектировании программ. Мы рассмотрели, как использовать простейшие
данные (числа) и простейшие операции (арифметические), как сочетать процедуры и
получать составные процедуры с помощью композиции, условных выражений и использования параметров, а также как строить абстрактные процедуры при помощи define.
Мы убедились, что процедуру можно рассматривать как схему локального развития
процесса; мы классифицировали некоторые общие схемы процессов, воплощенные в процедурах, строили о них умозаключения и производили их простейший алгоритмический
анализ. Кроме того, мы увидели, что процедуры высших порядков увеличивают выразительную силу нашего языка, позволяя оперировать общими методами вычисления, а
следовательно, и проводить рассуждения в их терминах. Это во многом и составляет
сущность программирования.
В этой главе мы будем рассматривать более сложные данные. Все процедуры главы 1
работают с простыми численными данными, а простых численных данных часто бывает
недостаточно для тех задач, которые мы хотим решать с помощью вычислений. Программы, как правило, пишут, чтобы моделировать сложные явления, и чаще всего приходится
строить вычислительные объекты, состоящие из нескольких частей, чтобы смоделировать многосторонние явления реального мира. Таким образом, в отличие от главы 1,
где наше внимание было в основном направлено на создание абстракций с помощью
сочетания процедур и построения составных процедур, в этой главе мы обращаемся к
другой важной характеристике всякого языка программирования: тем средствам, которые он предоставляет для создания абстракций с помощью сочетания объектов данных
и построения составных данных (compound data).
Для чего в языке программирования нужны составные данные? По тем же причинам,
по которым нужны составные процедуры: мы хотим повысить понятийный уровень, на
котором мы проектируем программы, хотим сделать наши проекты более модульными и

91
увеличить выразительную силу языка. Точно так же, как способность определять процедуры дает возможность работать с процессами на более высоком содержательном уровне,
чем уровень элементарных операций языка, способность конструировать составные объекты данных позволяет работать с данными на более высоком понятийном уровне, чем
уровень элементарных данных нашего языка.
Рассмотрим задачу проектирования системы для арифметических вычислений с рациональными числами. Мы можем представить себе операцию add-rat, которая принимает два рациональных числа и вычисляет их сумму. В терминах простейших данных, рациональное число можно рассматривать как два целых числа: числитель и знаменатель.
Таким образом, мы могли бы сконструировать программу, в которой всякое рациональное
число представлялось бы как пара целых (числитель и знаменатель) и add-rat была
бы реализована как две процедуры (одна из которых вычисляла бы числитель суммы,
а другая знаменатель). Однако это было бы крайне неудобно, поскольку нам приходилось бы следить, какие числители каким знаменателям соответствуют. Если бы системе
требовалось производить большое количество операций над большим количеством рациональных чисел, такие служебные детали сильно затемняли бы наши программы, не
говоря уже о наших мозгах. Было бы намного проще, если бы мы могли «склеить» числитель со знаменателем и получить пару — составной объект данных (compound data
object), — с которой наши программы могли бы обращаться способом, соответствующим
нашему представлению о рациональном числе как о едином понятии.
Кроме того, использование составных данных позволяет увеличить модульность программ. Если бы мы могли обрабатывать рациональные числа непосредственно как объекты, то можно было бы отделить ту часть программы, которая работает собственно с
рациональными числами, от деталей представления рационального числа в виде пары целых. Общий метод отделения частей программы, которые имеют дело с представлением
объектов данных, от тех частей, где эти объекты данных используются, — это мощная
методология проектирования, называемая абстракция данных (data abstraction). Мы
увидим, как с помощью абстракции данных программы становится легче проектировать,
поддерживать и изменять.
Использование составных данных ведет к настоящему увеличению выразительной силы нашего языка программирования. Рассмотрим идею порождения «линейной комбинации» ax+ by. Нам может потребоваться процедура, которая принимала бы как аргументы
a, b, x и y и возвращала бы значение ax + by. Если аргументы являются числами, это не
представляет никакой трудности, поскольку мы сразу можем определить процедуру
(define (linear-combination a b x y)
(+ (* a x) (* b y)))

Предположим, однако, что нас интересуют не только числа. Предположим, что нам
хотелось бы выразить в процедурных терминах идею о том, что можно строить линейные комбинации всюду, где определены сложение и умножение — для рациональных
и комплексных чисел, многочленов и многого другого. Мы могли бы выразить это как
процедуру в следующей форме:
(define (linear-combination a b x y)
(add (mul a x) (mul b y)))

92

Глава 2. Построение абстракций с помощью данных

где add и mul — не элементарные процедуры + и *, а более сложные устройства, которые проделывают соответствующие операции, какие бы типы данных мы ни передавали
как аргументы a, b, x и y. Здесь важнейшая деталь состоит в том, что единственное,
что требуется знать процедуре linear-combination об a, b, x и y — это то, что процедуры add и mul проделывают соответствующие действия. С точки зрения процедуры
linear-combination несущественно, что такое a, b, x и y, и еще менее существенно,
как они могут быть представлены через более простые данные. Этот же пример показывает, почему так важно, чтобы в нашем языке программирования была возможность прямо
работать с составными объектами: иначе у процедур, подобных linear-combination,
не было бы способа передать аргументы в add и mul, не зная деталей их устройства1 .
Мы начинаем эту главу с реализации описанной выше системы арифметики рациональных чисел. Это послужит основанием для обсуждения составных данных и абстракции данных. Как и в случае с составными процедурами, основная мысль состоит в том,
что абстракция является методом ограничения сложности, и мы увидим, как абстракция
данных позволяет нам возводить полезные барьеры абстракции (abstraction barriers)
между разными частями программы.
Мы увидим, что главное в работе с составными данными — то, что язык программирования должен предоставлять нечто вроде «клея», так, чтобы объекты данных могли
сочетаться, образуя более сложные объекты данных. Существует множество возможных
типов клея. На самом деле мы обнаружим, что составные данные можно порождать вообще без использования каких-либо специальных операций, относящихся к «данным» —
только с помощью процедур. Это еще больше размоет границу между «процедурами» и
«данными», которая уже к концу главы 1 оказалась весьма тонкой. Мы также исследуем некоторые общепринятые методы представления последовательностей и деревьев.
Важная идея в работе с составными данными — понятие замыкания (closure): клей для
сочетания объектов данных должен позволять нам склеивать не только элементарные
объекты данных, но и составные. Еще одна важная идея состоит в том, что составные
объекты данных могут служить стандартными интерфейсами (conventional interfaces),
так, чтобы модули программы могли сочетаться методом подстановки. Некоторые из этих
идей мы продемонстрируем с помощью простого графического языка, использующего замыкание.
Затем мы увеличим выразительную мощность нашего языка путем введения символьных выражений (symbolic expressions) — данных, элементарные части которых могут
быть произвольными символами, а не только числами. Мы рассмотрим различные варианты представления множеств объектов. Мы обнаружим, что, подобно тому, как одна
и та же числовая функция может вычисляться различными вычислительными процессами, существует множество способов представить некоторую структуру данных через
элементарные объекты, и выбор представления может существенно влиять на запросы
манипулирующих этими данными процессов к памяти и ко времени. Мы исследуем эти
1 Способность прямо оперировать процедурами увеличивает выразительную силу нашего языка программирования подобным же образом. Например, в разделе 1.3.1 мы ввели процедуру sum, которая принимает в
качестве аргумента процедуру term и вычисляет сумму значений term на некотором заданном интервале.
Чтобы определить sum, нам необходимо иметь возможность говорить о процедуре типа term как о едином
целом, независимо от того, как она выражена через более простые операции. Вообще говоря, не имей мы
понятия «процедуры», вряд ли мы и думать могли бы о возможности определения такой операции, как sum.
Более того, пока мы размышляем о суммировании, детали того, как term может быть составлен из более
простых операций, несущественны.

2.1. Введение в абстракцию данных

93

идеи в контексте символьного дифференцирования, представления множеств и кодирования информации.
После этого мы обратимся к задаче работы с данными, которые по-разному могут
быть представлены в различных частях программы. Это ведет к необходимости ввести
обобщенные операции (generic operations), которые обрабатывают много различных типов данных. Поддержка модульности в присутствии обобщенных операций требует более
мощных барьеров абстракции, чем тех, что получаются с помощью простой абстракции
данных. А именно, мы вводим программирование, управляемое данными (data-directed
programming) как метод, который позволяет проектировать представления данных отдельно, а затем сочетать их аддитивно (additively) (т. е., без модификации). Чтобы
проиллюстрировать силу этого подхода к проектированию систем, в завершение главы
мы применим то, чему в ней научились, к реализации пакета символьной арифметики многочленов, коэффициенты которых могут быть целыми, рациональными числами,
комплексными числами и даже другими многочленами.

2.1. Введение в абстракцию данных
В разделе 1.1.8 мы заметили, что процедура, которую мы используем как элемент
при создании более сложной процедуры, может рассматриваться не только как последовательность определенных операций, но и как процедурная абстракция: детали того, как
процедура реализована, могут быть скрыты, и сама процедура может быть заменена на
другую с подобным поведением. Другими словами, мы можем использовать абстракцию
для отделения способа использования процедуры от того, как эта процедура реализована в терминах более простых процедур. Для составных данных подобное понятие
называется абстракция данных (data abstraction). Абстракция данных — это методология, которая позволяет отделить способ использования составного объекта данных от
деталей того, как он составлен из элементарных данных.
Основная идея абстракции данных состоит в том, чтобы строить программы, работающие с составными данными, так, чтобы иметь дело с «абстрактными данными». То
есть, используя данные, наши программы не должны делать о них никаких предположений, кроме абсолютно необходимых для выполнения поставленной задачи. В то же
время «конкретное» представление данных определяется независимо от программ, которые эти данные используют. Интерфейсом между двумя этими частями системы служит
набор процедур, называемых селекторами (selectors) и конструкторами (constructors),
реализующих абстрактные данные в терминах конкретного представления. Чтобы проиллюстрировать этот метод, мы рассмотрим, как построить набор процедур для работы
с рациональными числами.

2.1.1. Пример: арифметические операции над рациональными числами
Допустим, нам нужно работать с рациональной арифметикой. Нам требуется складывать, вычитать, умножать и делить рациональные числа, а также проверять, равны ли
два рациональных числа друг другу.
Для начала предположим, что у нас уже есть способ построить рациональное число

94

Глава 2. Построение абстракций с помощью данных

из его числителя и знаменателя. Кроме того, мы предполагаем, что имея рациональное
число, мы можем получить его числитель и знаменатель. Допустим также, что эти
конструктор и два селектора доступны нам в виде процедур:
• (make-rat hni hdi) возвращает рациональное число, числитель которого целое
hni, а знаменатель — целое hdi.
• (numer hxi) возвращает числитель рационального числа hxi.

• (denom hxi) возвращает знаменатель рационального числа hxi.

Здесь мы используем мощную стратегию синтеза: мечтать не вредно (wishful thinking). Пока что мы не сказали, как представляется рациональное число и как должны
реализовываться процедуры numer, denom и make-rat. Тем не менее, если бы эти
процедуры у нас были, мы могли бы складывать, вычитать, умножать, делить и проверять
на равенство с помощью следующих отношений:
n2
n1 d2 + n2 d1
n1
+
=
d1
d2
d1 d2
n1
n2
n1 d2 − n2 d1

=
d1
d2
d1 d2
n1 n2
n1 n2
·
=
d1 d2
d1 d2
n1 d2
n1 /d1
=
n2 /d2
d1 n2
n1
n2
=
тогда и только тогда, когда n1 d2 = n2 d1
d1
d2
Мы можем выразить эти правила в процедурах:
(define (add-rat x y)
(make-rat (+ (* (numer x) (denom y))
(* (numer y) (denom x)))
(* (denom x) (denom y))))
(define (sub-rat x y)
(make-rat (- (* (numer x) (denom y))
(* (numer y) (denom x)))
(* (denom x) (denom y))))
(define (mul-rat x y)
(make-rat (* (numer x) (numer y))
(* (denom x) (denom y))))
(define (div-rat x y)
(make-rat (* (numer x) (denom y))
(* (denom x) (numer y))))

2.1. Введение в абстракцию данных

95

(define (equal-rat? x y)
(= (* (numer x) (denom y))
(* (numer y) (denom x))))

Теперь у нас есть операции над рациональными числами, определенные в терминах процедур — селекторов и конструкторов numer, denom и make-rat. Однако сами
эти процедуры мы еще не написали. Нам нужен какой-нибудь способ склеить вместе
числитель и знаменатель, чтобы получить рациональное число.
Пары
Для реализации конкретного уровня абстракции данных в нашем языке имеется
составная структура, называемая парой (pair), и она создается с помощью элементарной
процедуры cons. Эта процедура принимает два аргумента и возвращает объект данных,
который содержит эти два аргумента в качестве частей. Имея пару, мы можем получить
ее части с помощью элементарных процедур car и cdr2 . Таким образом, использовать
cons, car и cdr можно так:
(define x (cons 1 2))
(car x)
1
(cdr x)
2

Заметим, что пара является объектом, которому можно дать имя и работать с ним,
подобно элементарному объекту данных. Более того, можно использовать cons для
создания пар, элементы которых сами пары, и так далее:
(define x (cons 1 2))
(define y (cons 3 4))
(define z (cons x y))
(car (car z))
1
(car (cdr z))
3

В разделе 2.2 мы увидим, что из этой возможности сочетать пары следует возможность их использовать как строительные блоки общего назначения при создании любых
2 Cons означает construct (построить, сконструировать, собрать). Имена car и cdr происходят из исходной
реализации Лиспа на IBM 704. Схема адресации этой машины позволяла обращаться к «адресной» и «декрементной» частям ячейки памяти. Car означает Contents of Address Part of Register (содержимое адресной
части регистра), а cdr (произносится «куддер») означает Contents of Decrement Part of Register (содержимое
декрементной части регистра).

Глава 2. Построение абстракций с помощью данных

96

сложных структур данных. Один-единственный примитив составных данных пара, реализуемый процедурами cons, car и cdr, — вот и весь клей, который нам нужен.
Объекты данных, составленные из пар, называются данные со списковой структурой
(list-structured data).
Представление рациональных чисел
Пары позволяют нам естественным образом завершить построение системы рациональных чисел. Будем просто представлять рациональное число в виде пары двух целых
чисел: числителя и знаменателя. Тогда make-rat, numer и denom немедленно реализуются следующим образом3 .
(define (make-rat n d) (cons n d))
(define (numer x) (car x))
(define (denom x) (cdr x))

Кроме того, когда нам требуется выводить результаты вычислений, мы печатаем рациональное число, сначала выводя его числитель, затем косую черту и затем знаменатель4:
(define (print-rat x)
(newline)
(display (numer x))
(display "/")
(display (denom x)))

Теперь мы можем опробовать процедуры работы с рациональными числами:
(define one-half (make-rat 1 2))
(print-rat one-half)
1/2
3 Другой

способ определить селекторы и конструктор был бы

(define make-rat cons)
(define numer car)
(define denom cdr)
Первое определение связывает имя make-rat со значением выражения cons, то есть элементарной процедурой, которая строит пары. Таким образом, make-rat и cons становятся именами для одного и того же
элементарного конструктора.
Такое определение конструкторов и селекторов эффективно: вместо того, чтобы заставлять make-rat вызывать cons, мы делаем make-rat и cons одной и той же процедурой, так что когда вызывается make-rat,
происходит вызов только одной процедуры, а не двух. С другой стороны, это не дает работать отладочным
средствам, которые отслеживают вызовы процедур или устанавливают на них контрольные точки: Вам может
потребоваться следить за вызовами make-rat, но Вы уж точно никогда не захотите отслеживать каждый
вызов cons.
В этой книге мы решили не использовать такой стиль определений.
4 Display — элементарная процедура языка Scheme для печати данных. Другая элементарная процедура,
newline, переводит строку при печати. Эти процедуры не возвращают никакого полезного значения, так что
в примерах использования print-rat ниже, мы показываем только то, что печатает print-rat, а не то, что
интерпретатор выводит как значение print-rat.

2.1. Введение в абстракцию данных

97

(define one-third (make-rat 1 3))
(print-rat (add-rat one-half one-third))
5/6
(print-rat (mul-rat one-half one-third))
1/6
(print-rat (add-rat one-third one-third))
6/9

Как показывает последний пример, наша реализация рациональных чисел не приводит их к наименьшему знаменателю. Мы можем исправить это упущение, изменив
make-rat. Если у нас есть процедура gcd, вычисляющая наибольший общий делитель
двух целых чисел, вроде той, которая описана в разделе 1.2.5, мы можем с помощью
gcd сокращать числитель и знаменатель, прежде, чем построить пару:
(define (make-rat n d)
(let ((g (gcd n d)))
(cons (/ n g) (/ d g))))

Теперь мы имеем
(print-rat (add-rat one-third one-third))
2/3

как нам того и хотелось. Эта модификация была произведена путем изменения конструктора make-rat, и мы не тронули ни одну из процедур (скажем, add-rat или
mul-rat), которые реализуют сами операции.
Упражнение 2.1.
Определите улучшенную версию mul-rat, которая принимала бы как положительные, так и
отрицательные аргументы. Make-rat должна нормализовывать знак так, чтобы в случае, если
рациональное число положительно, то и его числитель, и знаменатель были бы положительны, а
если оно отрицательно, то чтобы только его числитель был отрицателен.

2.1.2. Барьеры абстракции
Прежде чем мы перейдем к другим примерам работы с составными данными и абстракцией данных, рассмотрим несколько вопросов, относящихся к примеру с рациональными числами. Мы определили операции над рациональными числами через конструктор
make-rat и селекторы numer и denom. В общем случае основная идея абстракции данных состоит в том, чтобы определить для каждого типа объектов данных набор базовых
операций, через которые будут выражаться все действия с объектами этого типа, и затем
при работе с данными использовать только этот набор операций.
Мы можем представить себе структуру системы работы с рациональными числами
так, как это показано на рис. 2.1. Горизонтальные линии обозначают барьеры абстракции (abstraction barriers), которые отделяют различные «уровни» системы друг от друга.

Глава 2. Построение абстракций с помощью данных

98

Программы, использующие рациональные числа
Рациональные числа в предметной области
add-rat sub-rat ...
Рациональные числа как числители со знаменателями
make-rat numer denom
Рациональные числа как пары
cons car cdr
То, как реализуются пары
Рис. 2.1. Барьеры абстракции данных в пакете для работы с рациональными числами.

На каждом из этих уровней барьер отделяет программы, которые используют абстрактные данные (сверху) от программ, которые реализуют эту абстракцию данных (внизу).
Программы, использующие рациональные числа, работают с ними исключительно в терминах процедур, которые пакет работы с рациональными числами предоставляет «для
общего пользования»: add-rat, sub-rat, mul-rat, div-rat и equal-rat?. В свою
очередь, эти процедуры используют только конструктор и селекторы make-rat, numer
и denom, которые сами реализованы при помощи пар. Детали реализации пар не имеют
значения для остальной части пакета работы с рациональными числами; существенно
только, что с парами можно работать при помощи cons, car и cdr. По существу,
процедуры на каждом уровне являются интерфейсами, которые определяют барьеры абстракции и связывают различные уровни.
У этой простой идеи много преимуществ. Одно из них состоит в том, что программы
становится намного проще поддерживать и изменять. Любая сложная структура может
быть представлена через элементарные структуры данных языка программирования многими способами. Разумеется, выбор представления влияет на программы, работающие
с этим представлением; так что, если когда-нибудь позднее его нужно будет изменить,
соответственно придется изменить и все эти программы. В случае больших программ
эта задача может быть весьма трудоемкой и дорогой, если зависимость от представления
не будет при проектировании ограничена несколькими программными модулями.
Например, другим способом решения задачи приведения рациональных чисел к наименьшему знаменателю было бы производить сокращение не тогда, когда мы конструируем число, а каждый раз, как мы к нему обращаемся. При этом потребуются другие
конструктор и селекторы:
(define (make-rat n d)
(cons n d))
(define (numer x)
(let ((g (gcd (car x) (cdr x))))

2.1. Введение в абстракцию данных

99

(/ (car x) g)))
(define (denom x)
(let ((g (gcd (car x) (cdr x))))
(/ (cdr x) g)))

Разница между этой реализацией и предыдущей состоит в том, когда мы вычисляем
НОД с помощью gcd. Если при типичном использовании рациональных чисел к числителю и знаменателю одного и того же рационального числа мы обращаемся по многу
раз, вычислять НОД лучше тогда, когда рациональное число конструируется. Если нет,
нам может быть выгодно подождать с его вычислением до времени обращения. В любом
случае, когда мы переходим от одной реализации к другой, нам ничего не нужно менять
в процедурах add-rat, sub-rat и прочих.
То, что мы ограничиваем зависимость от представления несколькими интерфейсными
процедурами, помогает нам и проектировать программы, и изменять их, поскольку таким
образом мы сохраняем гибкость и получаем возможность рассматривать другие реализации. Продолжая наш простой пример, представим себе, что мы строим пакет работы
с рациональными числами и не можем сразу решить, вычислять ли НОД при построении числа или при обращении к нему. Методология абстракции данных позволяет нам
отложить это решение, не теряя возможности продолжать разработку остальных частей
системы.
Упражнение 2.2.
Рассмотрим задачу представления отрезков прямой на плоскости. Каждый отрезок представляется
как пара точек: начало и конец. Определите конструктор make-segment и селекторы startsegment и end-segment, которые определяют представление отрезков в терминах точек. Далее,
точку можно представить как пару чисел: координата x и координата y. Соответственно, напишите конструктор make-point и селекторы x-point и y-point, которые определяют такое представление. Наконец, используя свои селекторы и конструктор, напишите процедуру midpointsegment, которая принимает отрезок в качестве аргумента и возвращает его середину (точку,
координаты которой являются средним координат концов отрезка). Чтобы опробовать эти процедуры, Вам потребуется способ печатать координаты точек:
(define (print-point p)
(newline)
(display "(")
(display (x-point p))
(display ",")
(display (y-point p))
(display ")"))
Упражнение 2.3.
Реализуйте представление прямоугольников на плоскости. (Подсказка: Вам могут потребоваться
результаты упражнения 2.2.) Определите в терминах своих конструкторов и селекторов процедуры,
которые вычисляют периметр и площадь прямоугольника. Теперь реализуйте другое представление
для прямоугольников. Можете ли Вы спроектировать свою систему с подходящими барьерами
абстракции так, чтобы одни и те же процедуры вычисления периметра и площади работали с
любым из Ваших представлений?

100

Глава 2. Построение абстракций с помощью данных

2.1.3. Что значит слово «данные»?
Свою реализацию рациональных чисел в разделе 2.1.1 мы начали с определения операций над рациональными числами add-rat, sub-rat и так далее в терминах трех
неопределенных процедур: make-rat, numer и denom. В этот момент мы могли думать об операциях как определяемых через объекты данных — числители, знаменатели и рациональные числа, — поведение которых определялось тремя последними процедурами.
Но что в точности означает слово данные (data)? Здесь недостаточно просто сказать
«то, что реализуется некоторым набором селекторов и конструкторов». Ясно, что не
любой набор из трех процедур может служить основой для реализации рациональных
чисел. Нам нужно быть уверенными в том, что если мы конструируем рациональное
число x из пары целых n и d, то получение numer и denom от x и деление их друг на
друга должно давать тот же результат, что и деление n на d. Другими словами, makerat, numer и denom должны удовлетворять следующему условию: для каждого целого
числа n и не равного нулю целого d, если x есть (make-rat n d), то
n
(numer x)
=
(denom x)
d
Это на самом деле единственное условие, которому должны удовлетворять make-rat,
numer и denom, чтобы служить основой для представления рациональных чисел. В
общем случае можно считать, что данные — это то, что определяется некоторым набором
селекторов и конструкторов, а также некоторыми условиями, которым эти процедуры
должны удовлетворять, чтобы быть правильным представлением5.
Эта точка зрения может послужить для определения не только «высокоуровневых»
объектов данных, таких как рациональные числа, но и объектов низкого уровня. Рассмотрим понятие пары, с помощью которого мы определили наши рациональные числа.
Мы ведь ни разу не сказали, что такое пара, и указывали только, что для работы с
парами язык дает нам процедуры cons, car и cdr. Но единственное, что нам надо
знать об этих процедурах — это что если мы склеиваем два объекта при помощи cons,
то с помощью car и cdr мы можем получить их обратно. То есть эти операции удовлетворяют условию, что для любых объектов x и y, если z есть (cons x y), то (car
z) есть x, а (cdr z) есть y. Действительно, мы упомянули, что три эти процедуры
включены в наш язык как примитивы. Однако любая тройка процедур, которая удовлетворяет вышеуказанному условию, может использоваться как основа реализации пар.
5 Как ни странно, эту мысль очень трудно строго сформулировать. Существует два подхода к такой формулировке. Один, начало которому положил Ч. А. Р. Хоар (Hoare 1972), известен как метод абстрактных
моделей (abstract models). Он формализует спецификацию вида «процедуры плюс условия» вроде описанной
выше в примере с рациональными числами. Заметим, что условие на представление рациональных чисел было
сформулировано в терминах утверждений о целых числах (равенство и деление). В общем случае абстрактные
модели определяют новые типы объектов данных в терминах типов данных, определенных ранее. Следовательно, утверждения об объектах данных могут быть проверены путем сведения их к утверждениям об объектах
данных, которые были определены ранее. Другой подход, который был введен Зиллесом из MIT, Гогеном, Тэтчером, Вагнером и Райтом из IBM (см. Thatcher, Wagner, and Wright 1978) и Гаттэгом из университета Торонто
(см. Guttag 1977), называется алгебраическая спецификация (algebraic specification). Этот подход рассматривает «процедуры» как элементы абстрактной алгебраической системы, чье поведение определяется аксиомами,
соответствующими нашим «условиям», и использует методы абстрактной алгебры для проверки утверждений
об объектах данных. Оба этих метода описаны в статье Лисков и Зиллеса (Liskov and Zilles 1975).

2.1. Введение в абстракцию данных

101

Эта идея ярко иллюстрируется тем, что мы могли бы реализовать cons, car и cdr без
использования каких-либо структур данных, а только при помощи одних процедур. Вот
эти определения:
(define (cons x y)
(define (dispatch m)
(cond ((= m 0) x)
((= m 1) y)
(else (error "Аргумент не 0 или 1 -- CONS" m))))
dispatch)
(define (car z) (z 0))
(define (cdr z) (z 1))

Такое использование процедур совершенно не соответствует нашему интуитивному понятию о том, как должны выглядеть данные. Однако для того, чтобы показать, что это
законный способ представления пар, требуется только проверить, что эти процедуры
удовлетворяют вышеуказанному условию.
Тонкость здесь состоит в том, чтобы заметить, что значение, возвращаемое cons,
есть процедура, — а именно процедура dispatch, определенная внутри cons, которая
принимает один аргумент и возвращает либо x, либо y в зависимости от того, равен ли
ее аргумент 0 или 1. Соответственно, (car z) определяется как применение z к 0. Следовательно, если z есть процедура, полученная из (cons x y), то z, примененная к 0,
вернет x. Таким образом, мы показали, что (car (cons x y)) возвращает x, как нам
и хотелось. Подобным образом (cdr (cons x y)) применяет процедуру, возвращаемую (cons x y), к 1, что дает нам y. Следовательно, эта процедурная реализация пар
законна, и если мы обращаемся к парам только с помощью cons, car и cdr, то мы не
сможем отличить эту реализацию от такой, которая использует «настоящие» структуры
данных.
Демонстрировать процедурную реализацию имеет смысл не для того, чтобы показать, как работает наш язык (Scheme, и вообще Лисп-системы, реализуют пары напрямую из соображений эффективности), а в том, чтобы показать, что он мог бы работать
и так. Процедурная реализация, хотя она и выглядит трюком, — совершенно адекватный способ представления пар, поскольку она удовлетворяет единственному условию,
которому должны соответствовать пары. Кроме того, этот пример показывает, что способность работать с процедурами как с объектами автоматически дает нам возможность
представлять составные данные. Сейчас это может показаться курьезом, но в нашем программистском репертуаре процедурные представления данных будут играть центральную
роль. Такой стиль программирования часто называют передачей сообщений (message
passing), и в главе 3, при рассмотрении вопросов моделирования, он будет нашим основным инструментом.
Упражнение 2.4.
Вот еще одно процедурное представление для пар. Проверьте для этого представления, что при
любых двух объектах x и y (car (cons x y)) возвращает x.
(define (cons x y)
(lambda (m) (m x y)))

102

Глава 2. Построение абстракций с помощью данных

(define (car z)
(z (lambda (p q) p)))
Каково соответствующее определение cdr? (Подсказка: Чтобы проверить, что это работает, используйте подстановочную модель из раздела 1.1.5.)
Упражнение 2.5.
Покажите, что можно представлять пары неотрицательных целых чисел, используя только числа
и арифметические операции, если представлять пару a и b как произведение 2a 3b . Дайте соответствующие определения процедур cons, car и cdr.
Упражнение 2.6.
Если представление пар как процедур было для Вас еще недостаточно сумасшедшим, то заметьте,
что в языке, который способен манипулировать процедурами, мы можем обойтись и без чисел (по
крайней мере, пока речь идет о неотрицательных числах), определив 0 и операцию прибавления 1
так:
(define zero (lambda (f) (lambda (x) x)))
(define (add-1 n)
(lambda (f) (lambda (x) (f ((n f) x)))))
Такое представление известно как числа Чёрча (Church numerals), по имени его изобретателя,
Алонсо Чёрча, того самого логика, который придумал λ-исчисление.
Определите one (единицу) и two (двойку) напрямую (не через zero и add-1). (Подсказка: вычислите (add-1 zero) с помощью подстановки.) Дайте прямое определение процедуры
сложения + (не в терминах повторяющегося применения add-1).

2.1.4. Расширенный пример: интервальная арифметика
Лиза П. Хакер проектирует систему, которая помогала бы в решении технических
задач. Одна из возможностей, которые она хочет реализовать в своей системе, — способность работать с неточными величинами (например, измеренные параметры физических
устройств), обладающими известной погрешностью, так что когда с такими приблизительными величинами производятся вычисления, результаты также представляют собой
числа с известной погрешностью.
Инженеры-электрики будут с помощью Лизиной системы вычислять электрические
величины. Иногда им требуется вычислить сопротивление Rp параллельного соединения
двух резисторов R1 и R2 по формуле
Rp =

1
1/R1 + 1/R2

Обычно сопротивления резисторов известны только с некоторой точностью, которую
гарантирует их производитель. Например, покупая резистор с надписью «6.8 Ом с погрешностью 10%», Вы знаете только то, что сопротивление резистора находится между
6.8 − 0.68 = 6.12 и 6.8 + 0.68 = 7.48 Ом. Так что если резистор в 6.8 Ом с погрешностью
10% подключен параллельно резистору в 4.7 Ом с погрешностью 5%, то сопротивление этой комбинации может быть примерно от 2.58 Ом (если оба резистора находятся

2.1. Введение в абстракцию данных

103

на нижней границе интервала допустимых значений) до 2.97 Ом (если оба резистора
находятся на верхней границе).
Идея Лизы состоит в том, чтобы реализовать «интервальную арифметику» как набор
арифметических операций над «интервалами» (объектами, которые представляют диапазоны возможных значений неточной величины). Результатом сложения, вычитания,
умножения или деления двух интервалов также будет интервал, который представляет
диапазон возможных значений результата.
Лиза постулирует существование абстрактного объекта, называемого «интервал», у
которого есть два конца: верхняя и нижняя границы. Кроме того, она предполагает,
что имея два конца интервала, мы можем сконструировать его при помощи конструктора make-interval. Сначала Лиза пишет процедуру сложения двух интервалов. Она
рассуждает так: минимальное возможное значение суммы равно сумме нижних границ
интервалов, а максимальное возможное значение сумме верхних границ интервалов.
(define (add-interval x y)
(make-interval (+ (lower-bound x) (lower-bound y))
(+ (upper-bound x) (upper-bound y))))

Кроме того, она вычисляет произведение двух интервалов путем нахождения минимума и максимума произведений концов интервалов и использования в качестве границ
интервала-результата. (min и max — примитивы, которые находят минимум и максимум
при любом количестве аргументов.)
(define (mul-interval x y)
(let ((p1 (* (lower-bound x) (lower-bound
(p2 (* (lower-bound x) (upper-bound
(p3 (* (upper-bound x) (lower-bound
(p4 (* (upper-bound x) (upper-bound
(make-interval (min p1 p2 p3 p4)
(max p1 p2 p3 p4))))

y)))
y)))
y)))
y))))

При делении двух интервалов Лиза умножает первый из них на интервал, обратный второму. Заметим, что границами обратного интервала являются числа, обратные верхней
и нижней границе исходного интервала, именно в таком порядке.
(define (div-interval x y)
(mul-interval x
(make-interval (/ 1.0 (upper-bound y))
(/ 1.0 (lower-bound y)))))
Упражнение 2.7.
Программа Лизы неполна, поскольку она не определила, как реализуется абстракция интервала.
Вот определение конструктора интервала:
(define (make-interval a b) (cons a b))
Завершите реализацию, определив селекторы upper-bound и lower-bound.
Упражнение 2.8.
Рассуждая в духе Лизы, опишите, как можно вычислить разность двух интервалов. Напишите
соответствующую процедуру вычитания, называемую sub-interval.

104

Глава 2. Построение абстракций с помощью данных

Упражнение 2.9.
Радиус (width) интервала определяется как половина расстояния между его верхней и нижней границами. Радиус является мерой неопределенности числа, которое обозначает интервал. Есть такие
математические операции, для которых радиус результата зависит только от радиусов интерваловаргументов, а есть такие, для которых радиус результата не является функцией радиусов аргументов. Покажите, что радиус суммы (или разности) двух интервалов зависит только от радиусов
интервалов, которые складываются (или вычитаются). Приведите примеры, которые показывают,
что для умножения или деления это не так.
Упражнение 2.10.
Бен Битобор, системный программист-эксперт, смотрит через плечо Лизы и замечает: неясно, что
должно означать деление на интервал, пересекающий ноль. Модифицируйте код Лизы так, чтобы
программа проверяла это условие и сообщала об ошибке, если оно возникает.
Упражнение 2.11.
Проходя мимо, Бен делает туманное замечание: «Если проверять знаки концов интервалов, можно
разбить mul-interval на девять случаев, из которых только в одном требуется более двух
умножений». Перепишите эту процедуру в соответствии с предложением Бена.

Отладив программу, Лиза показывает ее потенциальному пользователю, а тот жалуется, что она решает не ту задачу. Ему нужна программа, которая работала бы с числами,
представленными в виде срединного значения и аддитивной погрешности; например, ему
хочется работать с интервалами вида 3.5 ± 0.15, а не [3.35, 3.65]. Лиза возвращается к работе и исправляет этот недочет, добавив дополнительный конструктор и дополнительные
селекторы:
(define (make-center-width c w)
(make-interval (- c w) (+ c w)))
(define (center i)
(/ (+ (lower-bound i) (upper-bound i)) 2))
(define (width i)
(/ (- (upper-bound i) (lower-bound i)) 2))

К сожалению, большая часть Лизиных пользователей — инженеры. В реальных технических задачах речь обычно идет об измерениях с небольшой погрешностью, которая
измеряется как отношение радиуса интервала к его средней точке. Инженеры обычно указывают в параметрах устройств погрешность в процентах, как в спецификациях
резисторов, которые мы привели в пример выше.
Упражнение 2.12.
Определите конструктор make-center-percent, который принимает среднее значение и погрешность в процентах и выдает требуемый интервал. Нужно также определить селектор percent,
который для данного интервала выдает погрешность в процентах. Селектор center остается тем
же, что приведен выше.

2.1. Введение в абстракцию данных

105

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

После долгой работы Лиза П. Хакер сдает систему пользователям. Несколько лет
спустя, ужезабыв об этом, она получает жалобу от разгневанного пользователя Дайко
Поправича. Оказывается, Дайко заметил, что формулу для параллельных резисторов
можно записать двумя алгебраически эквивалентными способами:
R1 R2
R1 + R2
и

1
1/R1 + 1/R2

Он написал следующие две программы, каждая из которых считает формулу для параллельных резисторов своим способом:
(define (par1 r1 r2)
(div-interval (mul-interval r1 r2)
(add-interval r1 r2)))
(define (par2 r1 r2)
(let ((one (make-interval 1 1)))
(div-interval one
(add-interval (div-interval one r1)
(div-interval one r2)))))

Дайко утверждает, что для двух способов вычисления Лизина программа дает различные
результаты. Это серьезное нарекание.
Упражнение 2.14.
Покажите, что Дайко прав. Исследуйте поведение системы на различных арифметических выражениях. Создайте несколько интервалов A и B и вычислите с их помощью выражения A/A и A/B.
Наибольшую пользу Вы получите, если будете использовать интервалы, радиус которых составляет малую часть от среднего значения. Исследуйте результаты вычислений в форме центр/проценты
(см. упражнение 2.12).
Упражнение 2.15.
Ева Лу Атор, другой пользователь Лизиной программы, тоже заметила, что алгебраически эквивалентные, но различные выражения могут давать разные результаты. Она говорит, что формула для
вычисления интервалов, которая использует Лизину систему, будет давать более узкие границы
погрешности, если ее удастся записать так, чтобы ни одна переменная, представляющая неточную величину, не повторялась. Таким образом, говорит она, par2 «лучше» как программа для
параллельных резисторов, чем par1. Права ли она? Почему?

106

Глава 2. Построение абстракций с помощью данных
2

1
Рис. 2.2. Представление (cons 1 2) в виде стрелочной диаграммы.

Упражнение 2.16.
Объясните в общем случае, почему эквивалентные алгебраические выражения могут давать разные
результаты. Можете ли Вы представить себе пакет для работы с интервальной арифметикой,
который бы не обладал этим недостатком, или такое невозможно? (Предупреждение: эта задача
очень сложна.)

2.2. Иерархические данные и свойство замыкания
Как мы уже видели, пары служат элементарным «клеем», с помощью которого можно
строить составные объекты данных. На рис. 2.2 показан стандартный способ рисовать
пару — в данном случае, пару, которая сформирована выражением (cons 1 2). В этом
представлении, которое называется стрелочная диаграмма (box-and-pointer notation),
каждый объект изображается в виде стрелки (pointer), указывающей на какую-нибудь
ячейку. Ячейка, изображающая элементарный объект, содержит представление этого
объекта. Например, ячейка, соответствующая числу, содержит числовую константу.
Изображение пары состоит из двух ячеек, причем левая из них содержит (указатель
на) car этой пары, а правая — ее cdr.
Мы уже видели, что cons способен соединять не только числа, но и пары. (Вы
использовали это свойство, или, по крайней мере, должны были использовать, когда
выполняли упражнения 2.2 и 2.3). Как следствие этого, пары являются универсальным
материалом, из которого можно строить любые типы структур данных. На рис. 2.3 показаны два способа соединить числа 1, 2, 3 и 4 при помощи пар.
Возможность создавать пары, элементы которых сами являются парами, определяет
значимость списковой структуры как средства представления данных. Мы называем эту
возможность свойством замыкания (closure property) для cons. В общем случае, операция комбинирования объектов данных обладает свойством замыкания в том случае,
если результаты соединения объектов с помощью этой операции сами могут соединяться
этой же операцией6. Замыкание — это ключ к выразительной силе для любого средства
комбинирования, поскольку оно позволяет строить иерархические (hierarchical) структуры, то есть структуры, которые составлены из частей, которые сами составлены из
частей, и так далее.
6 Такое употребление слова «замыкание» происходит из абстрактной алгебры. Алгебраисты говорят, что
множество замкнуто относительно операции, если применение операции к элементам этого множества дает результат, который также является элементом множества. К сожалению, в сообществе программистов, пишущих
на Лиспе, словом «замыкание» обозначается еще и совершенно другое понятие: замыканием называют способ
представления процедур, имеющих свободные переменные. В этом втором смысле мы слово «замыкание» в
книге не используем.

2.2. Иерархические данные и свойство замыкания

107
4

3

1

4

1

2

(cons (cons 1 2)
(cons 3 4))

2

3

(cons (cons 1
(cons 2 3))
4)

Рис. 2.3. Два способа соединить 1, 2, 3 и 4 с помощью пар.

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

2.2.1. Представление последовательностей
Одна из полезных структур, которые можно построить с помощью пар — это последовательность (sequence), то есть упорядоченная совокупность объектов данных.
Разумеется, существует много способов представления последовательностей при помощи пар. Один, особенно простой, показан на рисунке 2.4, где последовательность 1, 2,
3, 4 представлена как цепочка пар. В каждой паре car — это соответствующий член
цепочки, а cdr — следующая пара цепочки. Cdr последней пары указывает на особое
значение, не являющееся парой, которое на диаграммах изображается как диагональная
линия, а в программах как значение переменной nil. Вся последовательность порождается несколькими вложенными операциями cons:
7 Идея, что средство комбинирования должно удовлетворять условию замыкания, очень проста. К сожалению, такие средства во многих популярных языках программирования либо не удовлетворяют этому условию,
либо делают использование замыканий неудобным. В Фортране и Бейсике элементы данных обычно группируются путем создания массивов — но массивы, элементы которых сами являются массивами, строить нельзя.
Паскаль и Си позволяют иметь структуры, члены которых являются структурами. Однако при этом требуется, чтобы программист напрямую работал с указателями и соблюдал ограничение, по которому каждое поле
структуры может содержать только элементы заранее заданной формы. В отличие от Лиспа с его парами, в
этих языках нет встроенного универсального клея, который позволял бы легко работать с составными данными
единым способом. Это ограничение дало Алану Перлису повод сказать в предисловии к этой книге: «В Паскале обилие объявляемых структур данных ведет к специализации функций, которая сдерживает и наказывает
случайное взаимодействие между ними. Лучше иметь 100 функций, которые работают с одной структурой
данных, чем 10 функций, работающих с 10 структурами».

Глава 2. Построение абстракций с помощью данных

108

1

2

3

4

Рис. 2.4. Последовательность 1, 2, 3, 4, представленная в виде цепочки пар.

(cons 1
(cons 2
(cons 3
(cons 4 nil))))

Такая последовательность пар, порождаемая вложенными cons-ами, называется список (list). В Scheme имеется примитив, который называется list и помогает строить списки8. Вышеуказанную последовательность можно было бы получить с помощью
(list 1 2 3 4). В общем случае
(list ha1 i ha2 i ... han i)

эквивалентно
(cons ha1 i (consha2 i (cons ... (cons han i nil) ... )))

По традиции, Лисп-системы печатают списки в виде последовательности их элементов,
заключенной в скобки. Таким образом, объект данных с рисунка 2.4 выводится как (1
2 3 4):
(define one-through-four (list 1 2 3 4))
one-through-four
(1 2 3 4)

Внимание: не путайте выражение (list 1 2 3 4) со списком (1 2 3 4), который
является результатом вычисления этого выражения. Попытка вычислить выражение (1
2 3 4) приведет к сообщению об ошибке, когда интерпретатор попробует применить
процедуру 1 к аргументам 1, 2, 3 и 4.
Мы можем считать, что процедура car выбирает первый элемент из списка, а cdr
возвращает подсписок, состоящий из всех элементов, кроме первого. Вложенные применения car и cdr могут выбрать второй, третий и последующие элементы списка9 .
8 В этой книге термин список всегда означает цепочку пар, которая завершается маркером конца списка.
Напротив, термин списковая структура (list structure) относится к любой структуре данных, составленной
из пар, а не только к спискам.
9 Поскольку записывать вложенные применения car и cdr громоздко, в диалектах Лиспа существуют
сокращения — например,

(cadr hаргi) = (car (cdr hаргi))

У всех таких процедур имена начинаются с c, а кончаются на r. Каждое a между ними означает операцию
car, а каждое d операцию cdr, и они применяются в том же порядке, в каком идут внутри имени. Имена
car и cdr сохраняются, поскольку простые их комбинации вроде cadr нетрудно произнести.

2.2. Иерархические данные и свойство замыкания

109

Конструктор cons порождает список, подобный исходному, но с дополнительным элементом в начале.
(car one-through-four)
1
(cdr one-through-four)
(2 3 4)
(car (cdr one-through-four))
2
(cons 10 one-through-four)
(10 1 2 3 4)
(cons 5 one-through-four)
(5 1 2 3 4)

Значение nil, которым завершается цепочка пар, можно рассматривать как последовательность без элементов, пустой список (empty list). Слово nil произошло от стяжения
латинского nihil, что значит «ничто»10 .
Операции со списками
Использованию пар для представления последовательностей элементов в виде списков сопутствуют общепринятые методы программирования, которые, работая со списками, последовательно их «уcdrивают». Например, процедура list-ref берет в качестве
аргументов список и число n и возвращает n-й элемент списка. Обычно элементы списка
нумеруют, начиная с 0. Метод вычисления list-ref следующий:
• Если n = 0, list-ref должна вернуть car списка.

• В остальных случаях list-ref должна вернуть (n − 1)-й элемент от cdr списка.

(define (list-ref items n)
(if (= n 0)
(car items)
(list-ref (cdr items) (- n 1))))
(define squares (list 1 4 9 16 25))
(list-ref squares 3)
16
10 Удивительно, сколько энергии при стандартизации диалектов Лиспа было потрачено на споры буквально
ни о чем: должно ли слово nil быть обычным именем? Должно ли значение nil являться символом? Должно
ли оно являться списком? Парой? В Scheme nil — обычное имя, и в этом разделе мы используем его как
переменную, значение которой — маркер конца списка (так же, как true — это обычная переменная, значение которой истина). Другие диалекты Лиспа, включая Common Lisp, рассматривают nil как специальный
символ. Авторы этой книги пережили слишком много скандалов со стандартизацией языков и хотели бы не
возвращаться к этим вопросам. Как только в разделе 2.3 мы введем кавычку, мы станем обозначать пустой
список в виде ’(), а от переменной nil полностью избавимся.

110

Глава 2. Построение абстракций с помощью данных

Часто мы проcdrиваем весь список. Чтобы помочь нам с этим, Scheme включает элементарную процедуру null?, которая определяет, является ли ее аргумент пустым списком.
Процедура length, которая возвращает число элементов в списке, иллюстрирует эту
характерную схему использования операций над списками:
(define (length items)
(if (null? items)
0
(+ 1 (length (cdr items)))))
(define odds (list 1 3 5 7))
(length odds)
4

Процедура length реализует простую рекурсивную схему. Шаг редукции таков:
• Длина любого списка равняется 1 плюс длина cdr этого списка
Этот шаг последовательно применяется, пока мы не достигнем базового случая:
• Длина пустого списка равна 0.
Мы можем вычислить length и в итеративном стиле:
(define (length items)
(define (length-iter a count)
(if (null? a)
count
(length-iter (cdr a) (+ 1 count))))
(length-iter items 0))

Еще один распространенный программистский прием состоит в том, чтобы «сconsить»
результат по ходу уcdrивания списка, как это делает процедура append, которая берет
в качестве аргументов два списка и составляет из их элементов один общий список:
(append squares odds)
(1 4 9 16 25 1 3 5 7)
(append odds squares)
(1 3 5 7 1 4 9 16 25)

Append также реализуется по рекурсивной схеме. Чтобы соединить списки list1 и
list2, нужно сделать следующее:
• Если список list1 пуст, то результатом является просто list2.

• В противном случае, нужно соединить cdr от list1 с list2, а к результату
прибавить car от list1 с помощью cons:
(define (append list1 list2)
(if (null? list1)
list2
(cons (car list1) (append (cdr list1) list2))))

2.2. Иерархические данные и свойство замыкания

111

Упражнение 2.17.
Определите процедуру last-pair, которая возвращает список, содержащий только последний
элемент данного (непустого) списка.
(last-pair (list 23 72 149 34))
(34)

Упражнение 2.18.
Определите процедуру reverse, которая принимает список как аргумент и возвращает список,
состоящий из тех же элементов в обратном порядке:
(reverse (list 1 4 9 16 25))
(25 16 9 4 1)

Упражнение 2.19.
Рассмотрим программу подсчета способов размена из раздела 1.2.2. Было бы приятно иметь возможность легко изменять валюту, которую эта программа использует, так, чтобы можно было,
например, вычислить, сколькими способами можно разменять британский фунт. Эта программа
написана так, что знание о валюте распределено между процедурами first-denomination и
count-change (которая знает, что существует пять видов американских монет). Приятнее было
бы иметь возможность просто задавать список монет, которые можно использовать при размене.
Мы хотим переписать процедуру cc так, чтобы ее вторым аргументом был список монет, а не
целое число, которое указывает, какие монеты использовать. Тогда у нас могли бы быть списки,
определяющие типы валют:
(define us-coins (list 50 25 10 5 1))
(define uk-coins (list 100 50 20 10 5 2 1 0.5))
Можно было бы вызывать cc следующим образом:
(cc 100 us-coins)
292
Это потребует некоторых изменений в программе cc. Ее форма останется прежней, но со вторым
аргументом она будет работать иначе, вот так:
(define (cc amount coin-values)
(cond ((= amount 0) 1)
((or (< amount 0) (no-more? coin-values)) 0)
(else
(+ (cc amount
(except-first-denomination coin-values))
(cc (- amount
(first-denomination coin-values))
coin-values)))))
Определите процедуры first-denomination, except-first-denomination и no-more? в
терминах элементарных операций над списковыми структурами. Влияет ли порядок списка coinvalues на результат, получаемый cc? Почему?

Глава 2. Построение абстракций с помощью данных

112

Упражнение 2.20.
Процедуры +, * и list принимают произвольное число аргументов. Один из способов определения таких процедур состоит в использовании точечной записи (dotted-tail notation). В определении процедуры список параметров с точкой перед именем последнего члена означает, что,
когда процедура вызывается, начальные параметры (если они есть) будут иметь в качестве значений начальные аргументы, как и обычно, но значением последнего параметра будет список всех
оставшихся аргументов. Например, если дано определение
(define (f x y . z) hтелоi)

то процедуру f можно вызывать с двумя и более аргументами. Если мы вычисляем

(f 1 2 3 4 5 6)
то в теле f переменная x будет равна 1, y будет равно 2, а z будет списком (3 4 5 6). Если дано
определение
(define (g . w) hтелоi)
то процедура g может вызываться с нулем и более аргументов. Если мы вычислим
(g 1 2 3 4 5 6)
то в теле g значением переменной w будет список (1 2 3 4 5 6)11 .
Используя эту нотацию, напишите процедуру same-parity, которая принимает одно или
более целое число и возвращает список всех тех аргументов, у которых четность та же, что у
первого аргумента. Например,
(same-parity 1 2 3 4 5 6 7)
(1 3 5 7)
(same-parity 2 3 4 5 6 7)
(2 4 6)

Отображение списков
Крайне полезной операцией является применение какого-либо преобразования к каждому элементу списка и порождение списка результатов. Например, следующая процедура умножает каждый элемент списка на заданное число.
(define (scale-list items factor)
(if (null? items)
nil
(cons (* (car items) factor)
(scale-list (cdr items) factor))))
(scale-list (list 1 2 3 4 5) 10)
(10 20 30 40 50)
11 Для

того, чтобы определить f и g при помощи lambda, надо было бы написать

(define f (lambda (x y . z) hтелоi))
(define g (lambda w hтелоi))

2.2. Иерархические данные и свойство замыкания

113

Мы можем выделить здесь общую идею и зафиксировать ее как схему, выраженную
в виде процедуры высшего порядка, в точности как в разделе 1.3. Здесь эта процедура
высшего порядка называется map. Map берет в качестве аргументов процедуру от одного аргумента и список, а возвращает список результатов, полученных применением
процедуры к каждому элементу списка12 :
(define (map proc items)
(if (null? items)
nil
(cons (proc (car items))
(map proc (cdr items)))))
(map abs (list -10 2.5 -11.6 17))
(10 2.5 11.6 17)
(map (lambda (x) (* x x))
(list 1 2 3 4))
(1 4 9 16)

Теперь мы можем дать новое определение scale-list через map:
(define (scale-list items factor)
(map (lambda (x) (* x factor))
items))

Map является важным конструктом, не только потому, что она фиксирует общую
схему, но и потому, что она повышает уровень абстракции при работе со списками. В
исходном определении scale-list рекурсивная структура программы привлекает внимание к поэлементной обработке списка. Определение scale-list через map устраняет этот уровень деталей и подчеркивает, что умножение преобразует список элементов
в список результатов. Разница между этими двумя определениями состоит не в том,
что компьютер выполняет другой процесс (это не так), а в том, что мы думаем об этом
процессе по-другому. В сущности, map помогает установить барьер абстракции, который
отделяет реализацию процедур, преобразующих списки, от деталей того, как выбираются
и комбинируются элементы списков. Подобно барьерам на рисунке 2.1, эта абстракция
позволяет нам свободно изменять низкоуровневые детали того, как реализованы списки,
сохраняя концептуальную схему с операциями, переводящими одни последовательности
12 Стандартная Scheme содержит более общую процедуру map, чем описанная здесь. Этот вариант map
принимает процедуру от n аргументов и n списков и применяет процедуру ко всем первым элементам списков,
всем вторым элементам списков и так далее. Возвращается список результатов. Например:

(map + (list 1 2 3) (list 40 50 60) (list 700 800 900))
(741 852 963)
(map (lambda (x y) (+ x (* 2 y)))
(list 1 2 3)
(list 4 5 6))
(9 12 15)

114

Глава 2. Построение абстракций с помощью данных

в другие. В разделе 2.2.3 такое использование последовательностей как способ организации программ рассматривается более подробно.
Упражнение 2.21.
Процедура square-list принимает в качестве аргумента список чисел и возвращает список
квадратов этих чисел.
(square-list (list 1 2 3 4))
(1 4 9 16)
Перед Вами два различных определения square-list. Закончите их, вставив пропущенные выражения:
(define (square-list items)
(if (null? items)
nil
(cons h??i h??i)))
(define (square-list items)
(map h??i h??i))
Упражнение 2.22.
Хьюго Дум пытается переписать первую из процедур square-list из упражнения 2.21 так,
чтобы она работала как итеративный процесс:
(define (square-list items)
(define (iter things answer)
(if (null? things)
answer
(iter (cdr things)
(cons (square (car things))
answer))))
(iter items nil))
К сожалению, такое определение square-list выдает список результатов в порядке, обратном
желаемому. Почему?
Затем Хьюго пытается исправить ошибку, обменяв аргументы cons:
(define (square-list items)
(define (iter things answer)
(if (null? things)
answer
(iter (cdr things)
(cons answer
(square (car things))))))
(iter items nil))
И так программа тоже не работает. Объясните это.
Упражнение 2.23.
Процедура for-each похожа на map. В качестве аргументов она принимает процедуру и список элементов. Однако вместо того, чтобы формировать список результатов, for-each просто

2.2. Иерархические данные и свойство замыкания

115
(3 4)

((1 2) 3 4)

(1 2)

3

1

4

2

Рис. 2.5. Структура, формируемая (cons (list 1 2) (list 3 4))

применяет процедуру по очереди ко всем элементам слева направо. Результаты применения процедуры к аргументам не используются вообще — for-each применяют к процедурам, которые
осуществляют какое-либо действие вроде печати. Например,
(for-each (lambda (x) (newline) (display x))
(list 57 321 88))
57
321
88
Значение, возвращаемое вызовом for-each (оно в листинге не показано) может быть каким
угодно, например истина. Напишите реализацию for-each.

2.2.2. Иерархические структуры
Представление последовательностей в виде списков естественно распространить на
последовательности, элементы которых сами могут быть последовательностями. Например, мы можем рассматривать объект ((1 2) 3 4), получаемый с помощью
(cons (list 1 2) (list 3 4))

как список с тремя членами, первый из которых сам является списком. В сущности, это
подсказывается формой, в которой результат печатается интерпретатором. Рисунок 2.5
показывает представление этой структуры в терминах пар.
Еще один способ думать о последовательностях последовательностей — деревья
(trees). Элементы последовательности являются ветвями дерева, а элементы, которые
сами по себе последовательности — поддеревьями. Рисунок 2.6 показывает структуру,
изображенную на рис. 2.5, в виде дерева.
Естественным инструментом для работы с деревьями является рекурсия, поскольку
часто можно свести операции над деревьями к операциям над их ветвями, которые сами
сводятся к операциям над ветвями ветвей, и так далее, пока мы не достигнем листьев
дерева. Например, сравним процедуру length из раздела 2.2.1 с процедурой countleaves, которая подсчитывает число листьев дерева:

Глава 2. Построение абстракций с помощью данных

116

((1 2) 3 4)

(1 2)
3
1

4

2

Рис. 2.6. Списковая структура с рис. 2.5, рассматриваемая как дерево.

(define x (cons (list 1 2) (list 3 4)))
(length x)
3
(count-leaves x)
4
(list x x)
(((1 2) 3 4) ((1 2) 3 4))
(length (list x x))
2
(count-leaves (list x x))
8

Чтобы реализовать count-leaves, вспомним рекурсивную схему вычисления length:
• Длина списка x есть 1 плюс длина cdr от x.
• Длина пустого списка есть 0.

Count-leaves очень похожа на эту схему. Значение для пустого списка остается
тем же:
• Count-leaves от пустого списка равна 0.
Однако в шаге редукции, когда мы выделяем car списка, нам нужно учесть, что
car сам по себе может быть деревом, листья которого нам требуется сосчитать. Таким
образом, шаг редукции таков:
• Count-leaves от дерева x есть count-leaves от (car x) плюс countleaves от (cdr x).
Наконец, вычисляя car-ы, мы достигаем листьев, так что нам требуется еще один
базовый случай:
• Count-leaves от листа равна 1.

2.2. Иерархические данные и свойство замыкания

117

Писать рекурсивные процедуры над деревьями в Scheme помогает элементарный предикат pair?, который проверяет, является ли его аргумент парой. Вот процедура целиком13 :
(define (count-leaves x)
(cond ((null? x) 0)
((not (pair? x)) 1)
(else (+ (count-leaves (car x))
(count-leaves (cdr x))))))
Упражнение 2.24.
Предположим, мы вычисляем выражение (list 1 (list 2 (list 3 4))). Укажите, какой
результат напечатает интерпретатор, изобразите его в виде стрелочной диаграммы, а также его
интерпретацию в виде дерева (как на рисунке 2.6).
Упражнение 2.25.
Укажите комбинации car и cdr, которые извлекают 7 из следующих списков:
(1 3 (5 7) 9)
((7))
(1 (2 (3 (4 (5 (6 7))))))
Упражнение 2.26.
Допустим, мы определили x и y как два списка:
(define x (list 1 2 3))
(define y (list 4 5 6))
Какой результат напечатает интерпретатор в ответ на следующие выражения:
(append x y)
(cons x y)
(list x y)
Упражнение 2.27.
Измените свою процедуру reverse из упражнения 2.18 так, чтобы получилась процедура deepreverse, которая принимает список в качестве аргумента и возвращает в качестве значения
список, где порядок элементов обратный и подсписки также обращены. Например:
(define x (list (list 1 2) (list 3 4)))
x
((1 2) (3 4))
(reverse x)
((3 4) (1 2))
(deep-reverse x)
((4 3) (2 1))
13 Порядок первых двух ветвей существен, поскольку пустой список удовлетворяет предикату null? и при
этом не является парой.

118

Глава 2. Построение абстракций с помощью данных

Упражнение 2.28.
Напишите процедуру fringe, которая берет в качестве аргумента дерево (представленное в виде списка) и возвращает список, элементы которого — все листья дерева, упорядоченные слева
направо. Например,
(define x (list (list 1 2) (list 3 4)))
(fringe x)
(1 2 3 4)
(fringe (list x x))
(1 2 3 4 1 2 3 4)

Упражнение 2.29.
Бинарный мобиль состоит из двух ветвей, левой и правой. Каждая ветвь представляет собой
стержень определенной длины, с которого свисает либо гирька, либо еще один бинарный мобиль.
Мы можем представить бинарный мобиль в виде составных данных, соединив две ветви (например,
с помощью list):
(define (make-mobile left right)
(list left right))
Ветвь составляется из длины length (которая должна быть числом) и структуры structure,
которая может быть либо числом (представляющим простую гирьку), либо еще одним мобилем:
(define (make-branch length structure)
(list length structure))
а. Напишите соответствующие селекторы left-branch и right-branch, которые возвращают левую и правую ветви мобиля, а также branch-length и branch-structure, которые
возвращают компоненты ветви.
б. С помощью этих селекторов напишите процедуру total-weight, которая возвращает общий
вес мобиля.
в. Говорят, что мобиль сбалансирован, если момент вращения, действующий на его левую ветвь,
равен моменту вращения, действующему на правую ветвь (то есть длина левого стержня, умноженная на вес груза, свисающего с него, равна соответствующему произведению для правой стороны),
и если все подмобили, свисающие с его ветвей, также сбалансированы. Напишите предикат, который проверяет мобили на сбалансированность.
г. Допустим, мы изменили представление мобилей, так что конструкторы теперь приняли такой
вид:
(define (make-mobile left right)
(cons left right))
(define (make-branch length structure)
(cons length structure))
Как много Вам нужно изменить в программах, чтобы перейти на новое представление?

2.2. Иерархические данные и свойство замыкания

119

Отображение деревьев
Подобно тому, как map может служить мощной абстракцией для работы с последовательностями, map, совмещенная с рекурсией, служит мощной абстракцией для работы с
деревьями. Например, процедура scale-tree, аналогичная процедуре scale-list из
раздела 2.2.1, принимает в качестве аргумента числовой множитель и дерево, листьями
которого являются числа. Она возвращает дерево той же формы, где каждое число умножено на множитель. Рекурсивная схема scale-tree похожа на схему count-leaves:
(define (scale-tree tree factor)
(cond ((null? tree) nil)
((not (pair? tree)) (* tree factor))
(else (cons (scale-tree (car tree) factor)
(scale-tree (cdr tree) factor)))))
(scale-tree (list 1 (list 2 (list 3 4) 5) (list 6 7))
10)
(10 (20 (30 40) 50) (60 70))

Другой способ реализации scale-tree состоит в том, чтобы рассматривать дерево
как последовательность поддеревьев и использовать map. Мы отображаем последовательность, масштабируя по очереди каждое поддерево, и возвращаем список результатов.
В базовом случае, когда дерево является листом, мы просто умножаем:
(define (scale-tree tree factor)
(map (lambda (sub-tree)
(if (pair? sub-tree)
(scale-tree sub-tree factor)
(* sub-tree factor)))
tree))

Многие операции над деревьями могут быть реализованы с помощью такого сочетания
операций над последовательностями и рекурсии.
Упражнение 2.30.
Определите процедуру square-tree, подобную процедуре square-list из упражнения 2.21. А
именно, square-tree должна вести себя следующим образом:
(square-tree
(list 1
(list 2 (list 3 4) 5)
(list 6 7)))
(1 (4 (9 16) 25) (36 49))
Определите square-tree как прямо (то есть без использования процедур высших порядков), так
и с помощью map и рекурсии.
Упражнение 2.31.
Абстрагируйте свой ответ на упражнение 2.30, получая процедуру tree-map, так, чтобы squaretree можно было определить следующим образом:

120

Глава 2. Построение абстракций с помощью данных

(define (square-tree tree) (tree-map square tree))
Упражнение 2.32.
Множество можно представить как список его различных элементов, а множество его подмножеств
как список списков. Например, если множество равно (1 2 3), то множество его подмножеств
равно (() (3) (2) (2 3) (1) (1 3) (1 2) (1 2 3)). Закончите следующее определение
процедуры, которая порождает множество подмножеств и дайте ясное объяснение, почему она
работает:
(define (subsets s)
(if (null? s)
(list nil)
(let ((rest (subsets (cdr s))))
(append rest (map h??i rest)))))

2.2.3. Последовательности как стандартные интерфейсы
При работе с составными данными мы подчеркивали, что абстракция позволяет проектировать программы, не увязая в деталях представления данных, и оставляет возможность экспериментировать с различными способами представления. В этом разделе мы
представляем еще один мощный принцип проектирования для работы со структурами
данных — использование стандартных интерфейсов (conventional interfaces).
В разделе 1.3 мы видели, как абстракции, реализованные в виде процедур высших
порядков, способны выразить общие схемы программ, которые работают с числовыми
данными. Наша способность формулировать подобные операции с составными данными
существенным образом зависит от того, в каком стиле мы манипулируем своими структурами данных. Например, рассмотрим следующую процедуру, аналогичную countleaves из раздела 2.2.2. Она принимает в качестве аргумента дерево и вычисляет
сумму квадратов тех из его листьев, которые являются нечетными числами:
(define (sum-odd-squares tree)
(cond ((null? tree) 0)
((not (pair? tree))
(if (odd? tree) (square tree) 0))
(else (+ (sum-odd-squares (car tree))
(sum-odd-squares (cdr tree))))))

При поверхностном взгляде кажется, что эта процедура очень сильно отличается от
следующей, которая строит список всех четных чисел Фибоначчи Fib(k), где k меньше
или равно данного целого числа n:
(define (even-fibs n)
(define (next k)
(if (> k n)
nil
(let ((f (fib k)))
(if (even? f)
(cons f (next (+ k 1)))
(next (+ k 1))))))
(next 0))

2.2. Иерархические данные и свойство замыкания

121

enumerate:
tree leaves

filter:
odd?

map:
square

accumulate:
+, 0

enumerate:
integers

map:
fib

filter:
even?

accumulate:
cons, ()

Рис. 2.7. Диаграммы потока сигналов для процедур sum-odd-squares (сверху) и
even-fibs (снизу) раскрывают схожесть этих двух программ.

Несмотря на то, что структурно эти процедуры весьма различны, более абстрактное описание двух процессов вычисления раскрывает немалую долю сходства. Первая
программа
• перечисляет листья дерева;

• просеивает их, отбирая нечетные;

• возводит в квадрат каждое из отобранных чисел; и

• накапливает результаты при помощи +, начиная с 0.
Вторая программа
• перечисляет числа от 1 до n;

• вычисляет для каждого из них число Фибоначчи;
• просеивает их, выбирая нечетные; и

• собирает их с помощью cons, начиная с пустого списка.
Специалисту по обработке сигналов покажется естественным выразить эти процессы
в терминах сигналов, проходящих через ряд стадий, каждая из которых реализует часть
плана программы, как это показано на рисунке 2.7. В процедуре sum-odd-squares
мы начинаем с перечислителя (enumerator), который порождает «сигнал», состоящий из
листьев данного дерева. Этот сигнал пропускается через фильтр (filter), который удаляет все элементы, кроме нечетных. Получившийся после этого сигнал, в свою очередь,
проходит отображение (map), которое представляет собой «преобразователь», применяющий к каждому элементу процедуру square. Наконец, выход отображения идет в
накопитель (accumulator), который собирает элементы при помощи +, начиная с 0. Для
even-fibs план аналогичен.
К сожалению, два определения процедур, приведенные выше, не отражают эту структуру потока сигналов. Например, если мы рассмотрим sum-oddsquares, мы обнаружим, что перечисление отчасти реализуется проверками null? и pair?, а отчасти
древовидно-рекурсивной структурой процедуры. Подобным образом, накопление отчасти
происходит в проверках, а отчасти в сложении, которое выполняется при рекурсивном

122

Глава 2. Построение абстракций с помощью данных

вызове. Вообще, никакая отдельная часть этих процедур не соответствует элементу потоковой диаграммы. Наши две процедуры дробят вычисление другим образом, раскидывая
перечисление по программе и смешивая его с отображением, просеиванием и накоплением. Если бы мы смогли организовать свои программы так, чтобы структура обработки
потока сигналов была ясно видна в написанных нами процедурах, то это сделало бы
смысл получаемого кода более прозрачным.
Операции над последовательностями
Итак, наши программы должны яснее отражать структуру потока сигналов. Ключевым моментом здесь будет перенос внимания на «сигналы», которые передаются от одной
стадии процесса к другой. Если мы представим эти сигналы в виде списков, то сможем
использовать операции над списками, чтобы реализовать обработку на каждом этапе.
Например, мы можем реализовать стадии отображения из диаграмм потоков сигналов с
помощью процедуры map из раздела 2.2.1:
(map square (list 1 2 3 4 5))
(1 4 9 16 25)

Просеивание последовательности, выбирающее только те элементы, которые удовлетворяют данному предикату, осуществляется при помощи
(define (filter predicate sequence)
(cond ((null? sequence) nil)
((predicate (car sequence))
(cons (car sequence)
(filter predicate (cdr sequence))))
(else (filter predicate (cdr sequence)))))

Например,
(filter odd? (list 1 2 3 4 5))
(1 3 5)

Накопление осуществляется посредством
(define (accumulate op initial sequence)
(if (null? sequence)
initial
(op (car sequence)
(accumulate op initial (cdr sequence)))))
(accumulate + 0 (list 1 2 3 4 5))
15
(accumulate * 1 (list 1 2 3 4 5))
120
(accumulate cons nil (list 1 2 3 4 5))
(1 2 3 4 5)

2.2. Иерархические данные и свойство замыкания

123

Чтобы реализовать диаграммы потока сигналов, нам остается только перечислить
последовательности элементов, с которыми мы будем работать. Для even-fibs нужно
породить последовательность целых чисел в заданном диапазоне. Это можно сделать
так:
(define (enumerate-interval low high)
(if (> low high)
nil
(cons low (enumerate-interval (+ low 1) high))))
(enumerate-interval 2 7)
(2 3 4 5 6 7)

Чтобы перечислить листья дерева, можно использовать такую процедуру14 :
(define (enumerate-tree tree)
(cond ((null? tree) nil)
((not (pair? tree)) (list tree))
(else (append (enumerate-tree (car tree))
(enumerate-tree (cdr tree))))))
(enumerate-tree (list 1 (list 2 (list 3 4)) 5))
(1 2 3 4 5)

Теперь мы можем переформулировать sum-odd-squares и even-fibs соответственно тому, как они изображены на диаграммах потока сигналов. В случае sum-oddsquares мы вычисляем последовательность листьев дерева, фильтруем ее, оставляя
только нечетные числа, возводим каждый элемент в квадрат и суммируем результаты:
(define (sum-odd-squares tree)
(accumulate +
0
(map square
(filter odd?
(enumerate-tree tree)))))

В случае с even-fibs мы перечисляем числа от 0 до n, порождаем для каждого из них
число Фибоначчи, фильтруем получаемую последовательность, оставляя только четные
элементы, и собираем результаты в список:
(define (even-fibs n)
(accumulate cons
nil
(filter even?
(map fib
(enumerate-interval 0 n)))))
14 Это в точности процедура fringe из упражнения 2.28. Здесь мы ее переименовали, чтобы подчеркнуть,
что она входит в семейство общих процедур обработки последовательностей.

124

Глава 2. Построение абстракций с помощью данных

Польза от выражения программ в виде операций над последовательностями состоит в
том, что эта стратегия помогает нам строить модульные проекты программ, то есть проекты, которые получаются путем сборки из относительно независимых частей. Можно
поощрять модульное проектирование, давая разработчику набор стандартных компонент
и унифицированный интерфейс, предназначенный для гибкого соединения этих компонентов.
Модульное построение является мощной стратегией управления сложностью в инженерном проектировании. Например, в реальных приложениях по обработке сигналов
проектировщики обычно строят системы путем каскадирования элементов, которые выбираются из стандартизованных семейств фильтров и преобразователей. Подобным образом операции над последовательностями составляют библиотеку стандартных элементов,
которые мы можем связывать и смешивать. К примеру, можно составить куски из процедур sum-odd-squares и even-fibs и получить программу, которая строит список
квадратов первых n+1 чисел Фибоначчи:
(define (list-fib-squares n)
(accumulate cons
nil
(map square
(map fib
(enumerate-interval 0 n)))))
(list-fib-squares 10)
(0 1 1 4 9 25 64 169 441 1156 3025)

Можно переставить куски и использовать их, чтобы вычислить произведение квадратов
нечетных чисел в последовательности:
(define (product-of-squares-of-odd-elements sequence)
(accumulate *
1
(map square
(filter odd? sequence))))
(product-of-squares-of-odd-elements (list 1 2 3 4 5))
225

Часто встречающиеся приложения по обработке данных можно также формулировать
в терминах операций над последовательностями. Допустим, у нас есть последовательность записей о служащих, и нам требуется найти зарплату самого высокооплачиваемого программиста. Пусть у нас будет селектор salary, который возвращает зарплату
служащего, и предикат programmer?, который проверяет, относится ли запись к программисту. Тогда мы можем написать:
(define (salary-of-highest-paid-programmer records)
(accumulate max
0
(map salary
(filter programmer? records))))

2.2. Иерархические данные и свойство замыкания

125

Все эти примеры дают лишь слабое представление об огромной области задач, выразимых в виде операций над последовательностями15.
Последовательности, здесь реализованные в виде списков, служат стандартным интерфейсом, который позволяет комбинировать обрабатывающие модули. Кроме того, если
мы представляем все структуры единым образом как последовательности, то нам удается
локализовать зависимость структур данных в своих программах в небольшом наборе операций с последовательностями. Изменяя эти последние, мы можем экспериментировать с
различными способами представления последовательностей, оставляя неприкосновенной
общую структуру своих программ. Этой возможностью мы воспользуемся в разделе 3.5,
когда обобщим парадигму обработки последовательностей и введем бесконечные последовательности.
Упражнение 2.33.
Заполните пропущенные выражения, так, чтобы получились определения некоторых базовых операций по работе со списками в виде накопления:
(define (map p sequence)
(accumulate (lambda (x y) h??i) nil sequence))
(define (append seq1 seq2)
(accumulate cons h??i h??i))
(define (length sequence)
(accumulate h??i 0 sequence))
Упражнение 2.34.
Вычисление многочлена с переменной x при данном значении x можно сформулировать в виде
накопления. Мы вычисляем многочлен
an xn + an−1 xn−1 + . . . + a1 x + a0
по известному алгоритму, называемому схема Горнера (Horner’s rule),
формулу в виде
(. . . (an x + an−1 )x + . . . + a1 )x + a0 )

которое переписывает

Другими словами, мы начинаем с an , умножаем его на x, и так далее, пока не достигнем a0 16 .
Заполните пропуски в следующей заготовке так, чтобы получить процедуру, которая вычисляет
15 Ричард Уотерс (Waters 1979) разработал программу, которая анализирует традиционные программы на
Фортране, представляя их в терминах отображений, фильтров и накоплений. Он обнаружил, что 90 процентов
кода в Пакете Научных Подпрограмм на Фортране хорошо укладывается в эту парадигму. Одна из причин
успеха Лиспа как языка программирования заключается в том, что списки дают стандартное средство представления упорядоченных множеств, с которыми можно работать при помощи процедур высших порядков.
Язык программирования APL своей мощности и красоте во многом обязан подобному же выбору. В APL
все данные выражаются как массивы, и существует универсальный и удобный набор общих операторов для
всевозможных действий над массивами.
16 Согласно Кнуту (Knuth 1981), это правило было сформулировано У. Г. Горнером в начале девятнадцатого
века, но на самом деле его использовал Ньютон более чем на сто лет раньше. По схеме Горнера многочлен
вычисляется с помощью меньшего количества сложений и умножений, чем при прямолинейном способе: вычислить сначала an xn , затем добавить an−1 xn−1 и так далее. На самом деле можно доказать, что любой
алгоритм для вычисления произвольных многочленов будет использовать по крайней мере столько сложений
и умножений, сколько схема Горнера, и, таким образом, схема Горнера является оптимальным алгоритмом для
вычисления многочленов. Это было доказано (для числа сложений) А. М. Островским в статье 1954 года,

126

Глава 2. Построение абстракций с помощью данных

многочлены по схеме Горнера. Предполагается, что коэффициенты многочлена представлены в
виде последовательности, от a0 до an .
(define (horner-eval x coefficient-sequence)
(accumulate (lambda (this-coeff higher-terms) h??i)
0
coefficient-sequence))
Например, чтобы вычислить 1 + 3x + 5x3 + x5 в точке x = 2, нужно ввести
(horner-eval 2 (list 1 3 0 5 0 1))

Упражнение 2.35.
Переопределите count-leaves из раздела 2.2.2 в виде накопления:
(define (count-leaves t)
(accumulate h??i h??i (map h??i

h??i)))

Упражнение 2.36.
Процедура accumulate-n подобна accumulate, только свой третий аргумент она воспринимает как последовательность последовательностей, причем предполагается, что все они содержат
одинаковое количество элементов. Она применяет указанную процедуру накопления ко всем первым элементам последовательностей, вторым элементам последовательностей и так далее, и возвращает последовательность результатов. Например, если s есть последовательность, состоящая
из четырех последовательностей, ((1 2 3) (4 5 6) (7 8 9) (10 11 12)), то значением
(accumulate-n + 0 s) будет последовательность (22 26 30). Заполните пробелы в следующем определении accumulate-n:
(define (accumulate-n op init seqs)
(if (null? (car seqs))
nil
(cons (accumulate op init h??i)
(accumulate-n op init h??i))))
Упражнение 2.37.
Предположим, что мы представляем векторы v = (vi ) как последовательности чисел, а матрицы
m = (mij ) как последовательности векторов (рядов матрицы). Например, матрица
3
2
1 2 3 4
4 4 5 6 6 5
6 7 8 9
представляется в виде последовательности ((1 2 3 4) (4 5 6 6) (6 7 8 9)). Имея такое
представление, мы можем использовать операции над последовательностями, чтобы кратко выразить основные действия над матрицами и векторами. Эти операции (описанные в любой книге по
матричной алгебре) следующие:
которая по существу заложила основы современной науки об оптимальных алгоритмах. Аналогичное утверждение для числа умножений доказал В. Я. Пан в 1966 году. Книга Бородина и Мунро (Borodin and Munro
1975) дает обзор этих результатов, а также других достижений в области оптимальных алгоритмов.

2.2. Иерархические данные и свойство замыкания
Скалярное произведение (dot-product v w) возвращает сумму

127
P

i vi wi ;

Произведение
матрицы и вектора (matrix-*-vector m v) возвращает вектор t, где ti =
P
j mij vi ;

Произведение
P матриц (matrix-*-matrix m n) возвращает
pij =
k mik nkj

матрицу

p,

где

Транспозиция (transpose m) возвращает матрицу n, где nij = mji
Скалярное произведение мы можем определить так17 :
(define (dot-product v w)
(accumulate + 0 (map * v w)))
Заполните пропуски в следующих процедурах для вычисления остальных матричных операций.
(Процедура accumulate-n описана в упражнении 2.36.)
(define (matrix-*-vector m v)
(map h??i m))
(define (transpose mat)
(accumulate-n h??i h??i mat))
(define (matrix-*-matrix m n)
(let ((cols (transpose n)))
(map h??i m)))
Упражнение 2.38.
Процедура accumulate известна также как fold-right (правая свертка), поскольку она комбинирует первый элемент последовательности с результатом комбинирования всех элементов справа
от него. Существует также процедура fold-left (левая свертка), которая подобна fold-right,
но комбинирует элементы в противоположном направлении:
(define (fold-left op initial sequence)
(define (iter result rest)
(if (null? rest)
result
(iter (op result (car rest))
(cdr rest))))
(iter initial sequence))
Каковы значения следующих выражений?
(fold-right / 1 (list 1 2 3))
(fold-left / 1 (list 1 2 3))
(fold-right list nil (list 1 2 3))
(fold-left list nil (list 1 2 3))
17 Это

определение использует расширенную версию map, описанную в сноске 12.

Глава 2. Построение абстракций с помощью данных

128

Укажите свойство, которому должна удовлетворять op, чтобы для любой последовательности
fold-right и fold-left давали одинаковые результаты.
Упражнение 2.39.
Закончите следующие определения reverse (упражнение 2.18) в терминах процедур foldright и fold-left из упражнения 2.38.
(define (reverse sequence)
(fold-right (lambda (x y) h??i) nil sequence))
(define (reverse sequence)
(fold-left (lambda (x y) h??i) nil sequence))

Вложенные отображения
Расширив парадигму последовательностей, мы можем включить в нее многие вычисления, которые обычно выражаются с помощью вложенных циклов18 . Рассмотрим
следующую задачу: пусть дано положительное целое число n; найти все такие упорядоченные пары различных целых чисел i и j, где 1 ≤ j < i ≤ n, что i + j является простым.
Например, если n равно 6, то искомые пары следующие:
i
j
i+j

2
1
3

3
2
5

4 4
1 3
5 7

5 6
2 1
7 7

6
5
11

Естественный способ организации этого вычисления состоит в том, чтобы породить последовательность всех упорядоченных пар положительных чисел, меньших n, отфильтровать ее, выбирая те пары, где сумма чисел простая, и затем для каждой пары (i, j),
которая прошла через фильтр, сгенерировать тройку (i, j, i + j).
Вот способ породить последовательность пар: для каждого целого i ≤ n перечислить целые числа j < i, и для каждых таких i и j породить пару (i, j). В терминах
операций над последовательностями, мы производим отображение последовательности
(enumerate-interval 1 n). Для каждого i из этой последовательности мы производим отображение последовательности (enumerate-interval 1 (- i 1)). Для
каждого j в этой последовательности мы порождаем пару (list i j). Это дает нам
последовательность пар для каждого i. Скомбинировав последовательности для всех
i (путем накопления через append), получаем необходимую нам последовательность
пар19 .
(accumulate append
nil
(map (lambda (i)
(map (lambda (j) (list i j))
(enumerate-interval 1 (- i 1))))
(enumerate-interval 1 n)))
18 Этот подход к вложенным отображениям нам показал Дэвид Тёрнер, чьи языки KRC и Миранда обладают
изящным формализмом для работы с такими конструкциями. Примеры из этого раздела (см. также упражнение 2.42) адаптированы из Turner 1981. В разделе 3.5.3 мы увидим, как этот подход можно обобщить на
бесконечные последовательности.
19 Здесь мы представляем пару в виде списка из двух элементов, а не в виде лисповской пары. Иначе говоря,
«пара» (i, j) представляется как (list i j), а не как (cons i j).

2.2. Иерархические данные и свойство замыкания

129

Комбинация из отображения и накопления через append в такого рода программах
настолько обычна, что мы ее выразим как отдельную процедуру:
(define (flatmap proc seq)
(accumulate append nil (map proc seq)))

Теперь нужно отфильтровать эту последовательность пар, чтобы найти те из них, где
сумма является простым числом. Предикат фильтра вызывается для каждой пары в
последовательности; его аргументом является пара и он должен обращаться к элементам
пары. Таким образом, предикат, который мы применяем к каждому элементу пары, таков:
(define (prime-sum? pair)
(prime? (+ (car pair) (cadr pair))))

Наконец, нужно породить последовательность результатов, отобразив отфильтрованную
последовательность пар при помощи следующей процедуры, которая создает тройку, состоящую из двух элементов пары и их суммы:
(define (make-pair-sum pair)
(list (car pair) (cadr pair) (+ (car pair) (cadr pair))))

Сочетание всех этих шагов дает нам процедуру целиком:
(define (prime-sum-pairs n)
(map make-pair-sum
(filter prime-sum?
(flatmap
(lambda (i)
(map (lambda (j) (list i j))
(enumerate-interval 1 (- i 1))))
(enumerate-interval 1 n)))))

Вложенные отображения полезны не только для таких последовательностей, которые
перечисляют интервалы. Допустим, нам нужно перечислить все перестановки множества
S, то есть все способы упорядочить это множество. Например, перестановки множества
{1, 2, 3} — это {1, 2, 3}, {1, 3, 2}, {2, 1, 3}, {2, 3, 1}, {3, 1, 2} и {3, 2, 1}. Вот план того, как
можно породить все перестановки S: Для каждого элемента x из S, нужно рекурсивно
породить все множество перестановок S − x20 , затем добавить x к началу каждой из них.
Для каждого x из S это дает множество всех перестановок S, которые начинаются с x.
Комбинация всех последовательностей для всех x дает нам все перестановки S 21 :
(define (permutations s)
(if (null? s)
(list nil)
20 Множество

; пустое множество?
; последовательность,
; содержащая пустое множество

S − x есть множество, состоящее из всех элементов S, кроме x.
с запятой в коде на Scheme начинают комментарии (comments). Весь текст, начиная от точки с запятой и заканчивая концом строки, интерпретатор игнорирует. В этой книге мы мало используем комментарии;
мы стараемся, чтобы программы документировали себя сами при помощи описательных имен переменных.
21 Точки

Глава 2. Построение абстракций с помощью данных

130

(flatmap (lambda (x)
(map (lambda (p) (cons x p))
(permutations (remove x s))))
s)))

Заметим, что такая стратегия сводит задачу порождения перестановок S к задаче порождения перестановок для множества, которое меньше, чем S. В граничном случае мы
добираемся до пустого списка, который представляет множество, не содержащее элементов. Для этого множества мы порождаем (list nil), которое является последовательностью из одного члена, а именно множества без элементов. Процедура remove,
которую мы используем внутри permutations, возвращает все элементы исходной последовательности, за исключением данного. Ее можно выразить как простой фильтр:
(define (remove item sequence)
(filter (lambda (x) (not (= x item)))
sequence))

Упражнение 2.40.
Определите процедуру unique-pairs, которая, получая целое число n, порождает последовательность пар (i, j), таких, что 1 ≤ j < i ≤ n. С помощью unique-pairs упростите данное выше
определение prime-sum-pairs.
Упражнение 2.41.
Напишите процедуру, которая находит все такие упорядоченные тройки различных положительных
целых чисел i, j и k, меньших или равных данному целому числу n, сумма которых равна данному
числу s.
Упражнение 2.42.
В «задаче о восьми ферзях» спрашивается, как расставить на шахматной доске восемь ферзей так,
чтобы ни один из них не бил другого (то есть никакие два ферзя не должны находиться на одной
вертикали, горизонтали или диагонали). Одно из возможных решений показано на рисунке 2.8.
Один из способов решать эту задачу состоит в том, чтобы идти поперек доски, устанавливая по
ферзю в каждой вертикали. После того, как k − 1 ферзя мы уже разместили, нужно разместить
k-го в таком месте, где он не бьет ни одного из тех, которые уже находятся на доске. Этот подход можно сформулировать рекурсивно: предположим, что мы уже породили последовательность
из всех возможных способов разместить k − 1 ферзей на первых k − 1 вертикалях доски. Для
каждого из этих способов мы порождаем расширенный набор позиций, добавляя ферзя на каждую горизонталь k-й вертикали. Затем эти позиции нужно отфильтровать, оставляя только те, где
ферзь на k-й вертикали не бьется ни одним из остальных. Продолжая этот процесс, мы породим
не просто одно решение, а все решения этой задачи.
Это решение мы реализуем в процедуре queens, которая возвращает последовательность решений задачи размещения n ферзей на доске n × n. В процедуре queens есть внутренняя процедура queen-cols, которая возвращает последовательность всех способов разместить ферзей на
первых k вертикалях доски.
(define (queens board-size)
(define (queen-cols k)
(if (= k 0)

2.2. Иерархические данные и свойство замыкания

131

Рис. 2.8. Решение задачи о восьми ферзях.

(list empty-board)
(filter
(lambda (positions) (safe? k positions))
(flatmap
(lambda (rest-of-queens)
(map (lambda (new-row)
(adjoin-position new-row k rest-of-queens))
(enumerate-interval 1 board-size)))
(queen-cols (- k 1))))))
(queen-cols board-size))
В этой процедуре rest-of-queens есть способ размещения k − 1 ферзя на первых k − 1 вертикалях, а new-row — это горизонталь, на которую предлагается поместить ферзя с k-й вертикали.
Завершите эту программу, реализовав представление множеств позиций ферзей на доске, включая
процедуру adjoin-position, которая добавляет нового ферзя на определенных горизонтали и
вертикали к заданному множеству позиций, и empty-board, которая представляет пустое множество позиций. Еще нужно написать процедуру safe?, которая для множества позиций определяет,
находится ли ферзь с k-й вертикали в безопасности от остальных. (Заметим, что нам требуется
проверять только то, находится ли в безопасности новый ферзь — для остальных ферзей безопасность друг от друга уже гарантирована.)
Упражнение 2.43.
У Хьюго Дума ужасные трудности при решении упражнения 2.42. Его процедура queens вроде
бы работает, но невероятно медленно. (Хьюго ни разу не удается дождаться, пока она решит хотя

132

Глава 2. Построение абстракций с помощью данных

Рис. 2.9. Узоры, порождаемые языком описания изображений.

бы задачу 6 × 6.) Когда Хьюго просит о помощи Еву Лу Атор, она указывает, что он поменял
местами порядок вложенных отображений в вызове процедуры flatmap, записав его в виде
(flatmap
(lambda (new-row)
(map (lambda (rest-of-queens)
(adjoin-position new-row k rest-of-queens))
(queen-cols (- k 1))))
(enumerate-interval 1 board-size))
Объясните, почему из-за такой перестановки программа работает медленно. Оцените, насколько
долго программа Хьюго будет решать задачу с восемью ферзями, если предположить, что программа, приведенная в упражнении 2.42, решает ее за время T .

2.2.4. Пример: язык описания изображений
В этой главе мы представляем простой язык для рисования картинок, иллюстрирующий силу абстракции данных и свойства замыкания; кроме того, он существенным
образом опирается на процедуры высших порядков. Язык этот спроектирован так, чтобы
легко было работать с узорами вроде тех, которые показаны на рисунке 2.9, составленными из элементов, которые повторяются в разных положениях и меняют размер22 . В
этом языке комбинируемые объекты данных представляются не как списковая структура, а как процедуры. Точно так же, как cons, которая обладает свойством замыкания,
позволила нам строить списковые структуры произвольной сложности, операции этого
языка, также обладающие свойством замыкания, позволяют нам строить сколь угодно
сложные узоры.
22 Этот язык описания картинок основан на языке, который создал Питер Хендерсон для построения изображений, подобных гравюре М. К. Эшера «Предел квадрата» (см. Henderson 1982). На гравюре изображен
повторяющийся с уменьшением элемент, подобно картинкам, получающимся при помощи процедуры squarelimit из этого раздела.

2.2. Иерархические данные и свойство замыкания

133

Язык описания изображений
Когда в разделе 1.1 мы начинали изучать программирование, мы подчеркивали важность описания языка через рассмотрение его примитивов, методов комбинирования и
методов абстракции. Мы будем следовать этой схеме и здесь.
Одно из элегантных свойств языка описания изображений состоит в том, что в нем
есть только один тип элементов, называемый рисовалкой (painter). Рисовалка рисует
изображение с необходимым смещением и масштабом, чтобы попасть в указанную рамку
в форме параллелограмма. Например, существует элементарная рисовалка wave, которая
порождает грубую картинку из линий, как показано на рисунке 2.10. Форма изображения
зависит от рамки — все четыре изображения на рисунке 2.10 порождены одной и той
же рисовалкой wave, но по отношению к четырем различным рамкам. Рисовалки могут
быть и более изощренными: элементарная рисовалка по имени rogers рисует портрет
основателя MIT Уильяма Бартона Роджерса, как показано на рисунке 2.1123 . Четыре
изображения на рисунке 2.11 нарисованы относительно тех же рамок, что и картинки
wave на рисунке 2.10.
При комбинировании изображений мы используем различные операции, которые
строят новые рисовалки из рисовалок, полученных в качестве аргументов. Например,
операция beside получает две рисовалки и порождает новую составную рисовалку, ко23 Уильям Бартон Роджерс (1804-1882) был основателем и первым президентом MIT. Будучи геологом и способным педагогом, он преподавал в Колледже Вильгельма и Марии, а также в университете штата Виргиния. В
1859 году он переехал в Бостон, где у него было больше времени для исследований, разработал план создания
«политехнического института» и служил первым Инспектором штата Массачусетс по газовым счетчикам.
Когда в 1861 году был основан MIT, Роджерс был избран его первым президентом. Роджерс исповедовал
идеал «полезного обучения», отличного от университетского образования его времени с чрезмерным вниманием
к классике, которое, как он писал, «стояло на пути более широкого, высокого и практического обучения и
преподавания в естественных и общественных науках». Это образование должно было отличаться и от узкого
образования коммерческих школ. По словам Роджерса:

Повсеместно проводимое разделение между практическим и научным работником совершенно
бесполезно, и весь опыт нашего времени показывает его полную несостоятельность.
Роджерс был президентом MIT до 1870 года, когда он ушел в отставку по состоянию здоровья. В 1878
году второй президент MIT Джон Ранкл оставил свой пост из-за финансового кризиса, вызванного биржевой
паникой 1873 года, и напряженной борьбы с попытками Гарварда поглотить MIT. Роджерс вернулся и оставался
на посту президента до 1881 года.
Роджерс умер от приступа во время своей речи перед студентами MIT на выпускной церемонии 1882 года.
В речи, посвященной его памяти и произнесенной в том же году, Ранкл приводит последние его слова:
«Стоя здесь и видя, чем стал Институт, . . . я вспоминаю о начале научных исследований. Я
вспоминаю, как сто пятьдесят лет назад Стивен Хейлс опубликовал статью на тему о светящемся
газе, где он утверждал, что его исследования показали, что 128 гран битумного угля. . . »
«Битумный уголь» — были его последние слова в этом мире. Он склонился вперед, как будто
справляясь со своими заметками, которые лежали перед ним на столе, затем медленно выпрямился, поднял руки, и был перенесен со сцены своих земных забот и триумфов в «завтра смерти»,
где решены тайны жизни, и бестелесный дух находит неизмеримое наслаждение в созерцании
новых и по-прежнему необъяснимых загадок бесконечного будущего.
По словам Фрэнсиса А. Уокера (третьего президента MIT):
Всю свою жизнь он провел с огромной верой и героизмом, и умер так, как, наверное, и должен
был желать столь превосходный рыцарь, в полном вооружении, на своем посту, и во время
самого акта исполнения общественных обязанностей.

Глава 2. Построение абстракций с помощью данных

134
...............
..

.............

.............

...............
.............
.............
.........
...............
...
...
...
...
...
...
.
...
..
.
.
...
...
....
...
...
....
....
..
...
.
..
.
......
...
..
..
...
...
..
...
.
.
...
.
.
.
.
... .....
...
..
...
.
...
...
.
.
..
...
...
..
...
...
...
...
..
...
...............................
...
......................
.
.
.
.
.
.
.
.
.
.
... ..........
....
..........
... .....
.
....
....
...
.
.
... ....
.
....
. .
...
....
.. ....
... .....
...
....
...
...
...
....
...
...
.
.
.
.
....
...
..
...
.
.
.
....
.
...
....
.......
.
...
... .....
.
....
.
.... ....
..........
.
... ...
.
.... ..
... .......
..
...
......
..
... ........
.
..
.
.
.
.
.
......
...
..
......
....
...
..
......
...
...
....
..
.
......
...
...
.
......
......
...
...
......
...
.. .....
...
.
......
. ..
...
.
.
.
.
......
.
.
...
.
.
...
.... ....
....
.
...... .
...
...
.
....
.
...
...
...
....
...
..
.
...
..
..
...
..
..
..
...
...
..
..
.
.
.
.
.
.
.
...
...
..
...
....
.
...
...
..
..
...
.
.
....
.
.
.
.
.............
..............
...............
............... ............. .. .............
..............
........
...............
..............
............. .
..............
...
...
...
..
.
...
.
.
...
.
...
..
...
...
...
...
..
.
..
..
....
...
....
...
..
...
.
...
..
..
...
....
...
.
.
.
..
... ....
.
..
.
...
.
... ...
.
..
...
..
..
.. ....
..
.
.
...
...........
... ....
.................
.
.
.
.
...
. . ...
....... ............
...
..
...
.
.
... ...
...
.
...
.. ....
... ...
...
...
...
...
... ...
...
...
.. ...
.
...
... .... .... ....
... ..
.....
..
... .... ...
... ..
.......
......
..
..
... ...
.
.. ....
..
.....
.. ..
.
...
.. ...
.. ....
....
...
..
...
.
...
...
.
...
..
..
...
.......
..
.
..
...
..
..
.....
... ...
...
...
....
.... ....
...
...
. ...
..
...
.
....
..
..
... ...
....
....
..
...
.
...
... ...
..
..
..
..
... ....
..
...
....
....
..
...
...
...
.
...
..
...
...
..
...
.
...
.
.
................ ..............
.. ............. .. ..............

......
.......

...
...........
...

..
......
...
.........
.
...
..
...
.
.
.
.
.
.
.
.......
...
...
...
...
...... ...
...
...
....... .....
...
...
.
.
.
.
.
.................
.....
.
.
.
.
.
.
.
.
.
... .
. ..
..
.......
... ..
...
...
... ..
...
...
.
...
...
...
.
.
.
.
.
.
....
.
.
.......
.
.
.
.
.
. ...
.....
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
..........
. ... ..............
.......... ...
...
.......... .... ...
....
...
.. .
..
.... ...
...
..
.... .....
.
.
.......
.
.
.
.
...
..
.. ... ..... ..... ....
.
.
.
... ..... ... .....
.....
...
.... ... ... ..
.......
.
. .
...
..
.. ....
... ... ..
...
... ..... .... ..........
.
.
...
.
.. ..
... ... ......
... ... .......
... ...
...
... ..........
.
...
.
.
.. ....
.
...
......
.
.......
.. .
.
.
. ..
........

.............
.............
............. ................
...............
.............
.............
.........
...
...
...
...
...
...
..
...
...
.
....
.......
.
.....
...
.
.
.....
.
...
.
.....
.
...
.
...
.
.....
.
.
...
.
.....
.....
.
.................................
..... ..........................................
...
.......
...........
.....
........
.
.
.
.
.
.
.
.
.. ........
.
.......
.... ........
.
.
.....
.
.
.
.
.
.
.
.......
..
.....
..
.
.
.
.
.
..... ...........
.
.
....... ...
..............
.....
.........
... ...........
...
...
.........
...
...
...
....
.........
...
....
..
.
.
.
.
.
.
.........
...
. ...
.
..
.
.
.
.
.
.
.
.
.........
...
.. ....
.
.
..
.
.
.
.
.
.
.
.....
...
...
.
..
...
...
...
...
...
...
...
..
..
...
...
.
..
..
.............
...............
...............
............... ............. ... .............
.............
.........

Рис. 2.10. Изображения, порожденные рисовалкой wave по отношению к четырем различным рамкам. Рамки, показанные пунктиром, не являются частью изображений.

торая рисует изображение первой рисовалки в левой половине рамки, а изображение
второй рисовалки в правой половине рамки. Подобным образом, below принимает две
рисовалки и порождает составную рисовалку, рисующую изображение первого аргумента
под изображением второго аргумента. Некоторые операции преобразуют одну рисовалку и получают другую. Например, flip-vert получает рисовалку и порождает новую,
рисующую изображение вверх ногами, а flip-horiz порождает рисовалку, рисующую
изображение исходной в зеркальном отображении.
На картинке 2.12 показан результат работы рисовалки, называемой wave4, который
строится в два этапа, начиная с wave:
(define wave2 (beside wave (flip-vert wave)))
(define wave4 (below wave2 wave2))

Строя таким образом составные рисовалки, мы используем тот факт, что рисовалки
замкнуты относительно средств комбинирования нашего языка. Beside или below от
двух рисовалок само является рисовалкой; следовательно, мы можем ее использовать
как элемент при построении еще более сложных рисовалок. Так же, как при построении

2.2. Иерархические данные и свойство замыкания

135

Рис. 2.11. Изображения Уильяма Бартона Роджерса, основателя и первого президента
MIT, нарисованные по отношению к тем же четырем рамкам, что и на рисунке 2.10
(первоначальное изображение печатается с разрешения музея MIT).

136

Глава 2. Построение абстракций с помощью данных

(define wave2
(beside wave (flip-vert wave)))

(define wave4
(below wave2 wave2))

Рис. 2.12. Построение составного изображения, начиная с рисовалки wave с рисунка 2.10

списковых структур с помощью cons, замкнутость наших данных относительно средств
комбинирования служит основой способности строить сложные структуры при помощи
всего лишь нескольких операций.
Раз мы можем комбинировать рисовалки, нам хотелось бы уметь выделять типичные
схемы их комбинирования. Операции над рисовалками мы реализуем как процедуры
языка Scheme. Это означает, что нам в языке изображений не требуется специального
механизма абстракции: поскольку средства комбинирования являются обычными процедурами Scheme, у нас автоматически есть право делать с операциями над рисовалками
все то, что мы можем делать с процедурами. Например, схему построения wave4 мы
можем абстрагировать в виде
(define (flipped-pairs painter)
(let ((painter2 (beside painter (flip-vert painter))))
(below painter2 painter2)))

и определить wave4 как пример применения этой схемы:
(define wave4 (flipped-pairs wave))

Мы можем определять и рекурсивные операции. Вот пример, который заставляет
рисовалки делиться и ветвиться направо, как показано на рисунках 2.13 и 2.14:
(define (right-split painter n)
(if (= n 0)
painter
(let ((smaller (right-split painter (- n 1))))
(beside painter (below smaller smaller)))))

2.2. Иерархические данные и свойство замыкания

right-split
n-1
identity
right-split
n-1
right-split n

upsplit
n-1

137

upsplit
n-1

identity

corner-split
n-1
right-split
n-1
right-split
n-1

corner-split n

Рис. 2.13. Рекурсивные планы для right-split и corner-split.

Можно порождать сбалансированные узоры, наращивая их не только направо, но и вверх
(см. упражнение 2.44 и рисунки 2.13 и 2.14):
(define (corner-split painter n)
(if (= n 0)
painter
(let ((up (up-split painter (- n 1)))
(right (right-split painter (- n 1))))
(let ((top-left (beside up up))
(bottom-right (below right right))
(corner (corner-split painter (- n 1))))
(beside (below painter top-left)
(below bottom-right corner))))))

Соответствующим образом расположив четыре копии corner-split, мы получаем схему под названием square-limit, применение которой к wave и rogers показано на
рисунке 2.9:
(define (square-limit painter n)
(let ((quarter (corner-split painter n)))
(let ((half (beside (flip-horiz quarter) quarter)))
(below (flip-vert half) half))))
Упражнение 2.44.
Определите процедуру up-split, которую использует corner-split. Она подобна rightsplit, но только меняет местами роли below и beside.

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

138

Глава 2. Построение абстракций с помощью данных

(right-split wave 4)

(right-split rogers 4)

(corner-split wave 4)

(corner-split rogers 4)

Рис. 2.14. Рекурсивные операции right-split и corner-split в применении к рисовалкам wave и rogers. Комбинирование четырех картинок corner-split дает симметричные узоры square-limit, как показано на рисунке 2.9.

2.2. Иерархические данные и свойство замыкания

139

Например, и flipped-pairs, и square-limit располагают определенным образом в виде квадрата четыре копии порождаемого рисовалкой изображения; они отличаются только тем, как они ориентируют эти копии. Один из способов абстрагировать
такую схему комбинирования рисовалок представлен следующей процедурой, которая
принимает четыре одноаргументных операции и порождает операцию над рисовалками,
которая трансформирует данную ей рисовалку с помощью этих четырех операций и
расставляет результаты по квадрату. Tl, tr, bl и br — это трансформации, которые
следует применить к верхней левой, верхней правой, нижней левой и нижней правой
копиям, соответственно.
(define (square-of-four tl tr bl br)
(lambda (painter)
(let ((top (beside (tl painter) (tr painter)))
(bottom (beside (bl painter) (br painter))))
(below bottom top))))

Тогда в терминах square-of-four можно определить flipped-pairs следующим
образом24 :
(define (flipped-pairs painter)
(let ((combine4 (square-of-four identity flip-vert
identity flip-vert)))
(combine4 painter)))

а square-limit можно выразить как25
(define (square-limit painter n)
(let ((combine4 (square-of-four flip-horiz identity
rotate180 flip-vert)))
(combine4 (corner-split painter n))))

Упражнение 2.45.
Right-split и up-split можно выразить как разновидности общей операции разделения.
Определите процедуру split с таким свойством, что вычисление
(define right-split (split beside below))
(define up-split (split below beside))
порождает процедуры right-split и up-split с таким же поведением, как и определенные
ранее.
24 Мы

также могли бы написать

(define flipped-pairs
(square-of-four identity flip-vert identity flip-vert))
25 Rotate180 поворачивает рисовалку на 180 градусов (см. упражнение 2.50). Вместо rotate180 мы могли
бы сказать (compose flip-vert flip-horiz), используя процедуру compose из упражнения 1.42.

Глава 2. Построение абстракций с помощью данных

140

вектор
edge2
рамки

вектор
origin
рамки

вектор
edge1
рамки

Точка (0,0) на экране

Рис. 2.15. Рамка представляется в виде трех векторов — начальной точки и двух краев.

Рамки
Прежде, чем мы сможем показать, как реализуются рисовалки и средства их комбинирования, нам нужно рассмотреть рамки. Рамку можно описать как три вектора —
вектор исходной точки и два вектора краев рамки. Вектор исходной точки Origin указывает смещение исходной точки рамки от некой абсолютной начальной точки, а векторы
краев Edge1 и Edge2 указывают смещение углов рамки от ее исходной точки. Если
края перпендикулярны, рамка будет прямоугольной. В противном случае рамка будет
представлять более общий случай параллелограмма. На рис. 2.15 показаны рамка и соответствующие ей вектора. В соответствии с принципами абстракции данных, нам пока
незачем указывать, каким образом представляются рамки; нужно только сказать, что
есть конструктор make-frame, который принимает три вектора и выдает рамку, и что
есть еще три селектора, origin-frame, edge1-frame и edge2-frame (см. упражнение 2.47).
Для определения изображений мы будем использовать координаты в единичном
квадрате (0 ≤ x, y ≤ 1). Каждой рамке мы сопоставляем отображение координат рамки (frame coordinate map), которое будет использоваться, чтобы сдвигать и масштабировать изображения так, чтобы они умещались в рамку. Это отображение трансформирует
единичный квадрат в рамку, переводя вектор v = (x, y) в сумму векторов
Origin(Frame) + x · Edge1 (Frame) + y · Edge2 (Frame)
Например, (0, 0) отображается в исходную точку рамки, (1, 1) в вершину, противоположную исходной точке по диагонали, а (0.5, 0.5) в центр рамки. Мы можем создать
отображение координат рамки при помощи следующей процедуры26 :
(define (frame-coord-map frame)
(lambda (v)
26 Frame-coord-map использует векторные операции, определенные ниже в упражнении 2.46, и мы предполагаем, что они реализованы для какого-нибудь представления векторов. Благодаря абстракции данных,
неважно, каково это представление; нужно только, чтобы операции над векторами вели себя правильно.

2.2. Иерархические данные и свойство замыкания

141

(add-vect
(origin-frame frame)
(add-vect (scale-vect (xcor-vect v)
(edge1-frame frame))
(scale-vect (ycor-vect v)
(edge2-frame frame))))))

Заметим, что применение frame-coord-map к рамке дает нам процедуру, которая,
получая вектор, возвращает тоже вектор. Если вектор-аргумент находится в единичном
квадрате, вектор-результат окажется в рамке. Например,
((frame-coord-map a-frame) (make-vect 0 0))

возвращает тот же вектор, что и
(origin-frame a-frame)

Упражнение 2.46.
Двумерный вектор v, идущий от начала координат к точке, можно представить в виде пары,
состоящей из x-координаты и y-координаты. Реализуйте абстракцию данных для векторов, написав
конструктор make-vect и соответствующие селекторы xcor-vect и ycor-vect. В терминах
своих селекторов и конструктора реализуйте процедуры add-vect, sub-vect и scale-vect,
которые выполняют операции сложения, вычитания векторов и умножения вектора на скаляр:
(x1 , y1 ) + (x2 , y2 ) = (x1 + x2 , y1 + y2 )
(x1 , y1 ) − (x2 , y2 ) = (x1 − x2 , y1 − y2 )
s · (x, y) = (sx, sy)
Упражнение 2.47.
Вот два варианта конструкторов для рамок:
(define (make-frame origin edge1 edge2)
(list origin edge1 edge2))
(define (make-frame origin edge1 edge2)
(cons origin (cons edge1 edge2)))
К каждому из этих конструкторов добавьте соответствующие селекторы, так, чтобы получить
реализацию рамок.

Рисовалки
Рисовалка представляется в виде процедуры, которая, получая в качестве аргумента
рамку, рисует определенное изображение, отмасштабированное и сдвинутое так, чтобы
уместиться в эту рамку. Это означает, что если есть рисовалка p и рамка f, то мы
можем получить изображение, порождаемое p, в f, позвав p с f в качестве аргумента.
Детали того, как реализуются элементарные рисовалки, зависят от конкретных характеристик графической системы и типа изображения, которое надо получить. Например, пусть у нас будет процедура draw-line, которая рисует на экране отрезок между

142

Глава 2. Построение абстракций с помощью данных

двумя указанными точками. Тогда мы можем создавать из списков отрезков рисовалки
для изображений, состоящих из этих отрезков, вроде рисовалки wave с рисунка 2.10,
таким образом27 :
(define (segments->painter segment-list)
(lambda (frame)
(for-each
(lambda (segment)
(draw-line
((frame-coord-map frame) (start-segment segment))
((frame-coord-map frame) (end-segment segment))))
segment-list)))

Отрезки даются в координатах по отношению к единичному квадрату. Для каждого сегмента в списке рисовалка преобразует концы отрезка с помощью отображения координат
рамки и рисует отрезок между точками с преобразованными координатами.
Представление рисовалок в виде процедур воздвигает в языке построения изображений мощный барьер абстракции. Мы можем создавать и смешивать множество типов
элементарных рисовалок, в зависимости от имеющихся возможностей графики. Детали
их реализации несущественны. Любая процедура, если она принимает в качестве аргумента рамку и рисует в ней что-нибудь должным образом отмасштабированное, может
служить рисовалкой28 .
Упражнение 2.48.
Направленный отрезок на плоскости можно представить в виде пары векторов: вектор от начала
координат до начала отрезка и вектор от начала координат до конца отрезка. Используйте свое
представление векторов из упражнения 2.46 и определите представление отрезков с конструктором
make-segment и селекторами start-segment и end-segment.
Упражнение 2.49.
С помощью segments->painter определите следующие элементарные рисовалки:
а. Рисовалку, которая обводит указанную рамку.
б. Рисовалку, которая рисует «Х», соединяя противоположные концы рамки.
в. Рисовалку, которая рисует ромб, соединяя между собой середины сторон рамки.
г. Рисовалку wave.
27 Процедура segments->painter использует представление отрезков прямых, описанное ниже в упражнении 2.48. Кроме того, она использует процедуру for-each, описанную в упражнении 2.23.
28 Например, рисовалка rogers с рисунка 2.11 была получена из полутонового черно-белого изображения.
Для каждой точки в указанной рамке рисовалка rogers определяет точку исходного изображения, которая в
нее отображается, и соответствующим образом ее окрашивает. Разрешая себе иметь различные типы рисовалок, мы пользуемся идеей абстрактных данных, описанной в разделе 2.1.3, где мы говорили, что представление
рациональных чисел может быть каким угодно, пока соблюдается соответствующее условие. Здесь мы используем то, что рисовалку можно реализовать как угодно, лишь бы она что-то изображала в указанной рамке. В
разделе 2.1.3 показывается и то, как реализовать пары в виде процедур. Рисовалки — это наш второй пример
процедурного представления данных.

2.2. Иерархические данные и свойство замыкания

143

Преобразование и комбинирование рисовалок
Операции над рисовалками (flip-vert или beside, например) создают новые рисовалки, которые вызывает исходные рисовалки по отношению к рамкам, производным
от рамок-аргументов. Таким образом, скажем, flip-vert не требуется знать, как работает рисовалка, чтобы перевернуть ее — ей нужно только уметь перевернуть рамку
вверх ногами: перевернутая рисовалка просто использует исходную, но в обращенной
рамке.
Операции над рисовалками основываются на процедуре transform-painter, которая в качестве аргументов берет рисовалку и информацию о том, как преобразовать
рамку, а возвращает новую рисовалку. Когда преобразованная рисовалка вызывается по
отношению к какой-либо рамке, она преобразует рамку и вызывает исходную рисовалку
по отношению к ней. Аргументами transform-painter служат точки (представленные в виде векторов), указывающие углы новой рамки: будучи отображенной на рамку,
первая точка указывает исходную точку новой рамки, а две других — концы краевых
векторов. Таким образом, аргументы, лежащие в пределах единичного квадрата, определяют рамку, которая содержится внутри исходной рамки.
(define (transform-painter painter origin corner1 corner2)
(lambda (frame)
(let ((m (frame-coord-map frame)))
(let ((new-origin (m origin)))
(painter
(make-frame new-origin
(sub-vect (m corner1) new-origin)
(sub-vect (m corner2) new-origin)))))))

Вот как перевернуть изображение в рамке вертикально:
(define (flip-vert painter)
(transform-painter painter
(make-vect 0.0 1.0)
; новая исходная точка
(make-vect 1.0 1.0)
; новый конец edge1
(make-vect 0.0 0.0))) ; новый конец edge2

При помощи transform-painter нам нетрудно будет определять новые трансформации.
Например, можно определить рисовалку, которая рисует уменьшенную копию исходного
изображения в верхней правой четверти рамки:
(define (shrink-to-upper-right painter)
(transform-painter painter
(make-vect 0.5 0.5)
(make-vect 1.0 0.5)
(make-vect 0.5 1.0)))

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

144

Глава 2. Построение абстракций с помощью данных

(define (rotate90 painter)
(transform-painter painter
(make-vect 1.0 0.0)
(make-vect 1.0 1.0)
(make-vect 0.0 0.0)))

А эта сжимает изображение по направлению к центру рамки30 :
(define (squash-inwards painter)
(transform-painter painter
(make-vect 0.0 0.0)
(make-vect 0.65 0.35)
(make-vect 0.35 0.65)))

Преобразования рамок являются также основой для определения средств комбинирования двух или более рисовалок. Например, процедура beside берет две рисовалки,
трансформирует их так, чтобы они работали соответственно в левой и правой половинах
рамки-аргумента, и создает новую составную рисовалку. Когда составной рисовалке передается рамка, она вызывает первую из преобразованных рисовалок над левой половиной
рамки, а вторую над правой половиной:
(define (beside painter1 painter2)
(let ((split-point (make-vect 0.5 0.0)))
(let ((paint-left
(transform-painter painter1
(make-vect 0.0
split-point
(make-vect 0.0
(paint-right
(transform-painter painter2
split-point
(make-vect 1.0
(make-vect 0.5
(lambda (frame)
(paint-left frame)
(paint-right frame)))))

0.0)
1.0)))

0.0)
1.0))))

Обратите внимание, как абстракция данных, и особенно представление рисовалок в виде процедур, облегчает реализацию beside. Процедуре beside не требуется ничего
знать о деталях рисовалок-компонент, кроме того, что каждая из них что-то изобразит в
указанной ей рамке.
Упражнение 2.50.
Определите преобразование flip-horiz, которое обращает изображение вокруг горизонтальной
оси, а также преобразования, которые вращают рисовалки против часовой стрелки на 180 и 270
градусов.
30 Ромбовидные изображения на рисунках 2.10 и 2.11 были получены с помощью squash-inwards, примененной к wave и rogers.

2.2. Иерархические данные и свойство замыкания

145

Упражнение 2.51.
Определите для рисовалок операцию below. Below принимает в качестве аргументов две рисовалки. Когда получившейся рисовалке передается рамка, она рисует в нижней ее половине при
помощи первой рисовалки, а в верхней при помощи второй. Определите below двумя способами —
один раз аналогично процедуре beside, как она приведена выше, а второй раз через beside и
операции вращения (см. упражнение 2.50).

Уровни языка помогают устойчивому проектированию
Язык построения изображений использует некоторые из важнейших введенных нами
идей, относящихся к абстракции процедур и данных. Базовая абстракция данных, рисовалки, реализуется при помощи процедурного представления, и благодаря этому наш
язык может работать с различными графическими системами единым образом. Средства
комбинирования обладают свойством замыкания, и это позволяет нам легко возводить
сложные построения. Наконец, все средства абстракции процедур доступны нам для
того, чтобы абстрагировать средства комбинирования рисовалок.
Нам удалось бросить взгляд и еще на одну существеннейшую идею касательно проектирования языков и программ. Это подход уровневого проектирования (stratified
design), представление, что сложной системе нужно придавать структуру при помощи
последовательности уровней, которая описывается последовательностью языков. Каждый
из уровней строится путем комбинации частей, которые на этом уровне рассматриваются
как элементарные, и части, которые строятся на каждом уровне, работают как элементарные на следующем уровне. Язык, который используется на каждом уровне такого
проекта, включает примитивы, средства комбинирования и абстракции, соответствующие этому уровню подробности.
Уровневое проектирование пронизывает всю технику построения сложных систем.
Например, при проектировании компьютеров резисторы и транзисторы сочетаются (и
описываются при помощи языка аналоговых схем), и из них строятся и-, или- элементы
и им подобные, служащие основой языка цифровых схем31 . Из этих элементов строятся процессоры, шины и системы памяти, которые в свою очередь служат элементами в
построении компьютеров при помощи языков, подходящих для описания компьютерной
архитектуры. Компьютеры, сочетаясь, дают распределенные системы, которые описываются при помощи языков описания сетевых взаимодействий, и так далее.
Как миниатюрный пример уровневого подхода, наш язык описания изображений использует элементарные объекты (элементарные рисовалки), создаваемые при помощи
языка, в котором описываются точки и линии и создаются списки отрезков для рисовалки segments->painter либо градации серого цвета в рисовалке вроде rogers.
Большей частью наше описание языка картинок было сосредоточено на комбинировании этих примитивов с помощью геометрических комбинаторов вроде beside и below.
Работали мы и на более высоком уровне, где beside и below рассматривались как
примитивы, манипулируемые языком, операции которого, такие как square-of-four,
фиксируют стандартные схемы сочетания геометрических комбинаторов.
Уровневое проектирование помогает придать программам устойчивость (robustness),
то есть повышает вероятность, что небольшое изменение в спецификации потребует относительно малых изменений в программе. Например, предположим, что нам нужно из31 Один

из таких языков описывается в разделе 3.3.4.

146

Глава 2. Построение абстракций с помощью данных

менить картинку, основанную на рисовалке wave, которая показана на рисунке 2.9. Мы
можем работать на самом низком уровне, изменяя конкретный вид элемента wave; можем работать на промежуточном уровне и менять то, как corner-split воспроизводит
wave; можем на самом высоком уровне изменять то, как square-limit расставляет
четыре копии по углам. В общем, каждый уровень такого проекта дает свой словарь для
описания характеристик системы и свой тип возможных изменений.
Упражнение 2.52.
Измените предел квадрата рисовалки wave, показанный на рисунке 2.9, работая на каждом из
вышеописанных уровней. А именно:
а. Добавьте новые отрезки к элементарной рисовалке wave из упражнения 2.49 (например,
изобразив улыбку).
б. Измените шаблон, который порождает corner-split (например, используя только одну
копию образов up-split и right-split вместо двух).
в. Измените версию square-limit, использующую square-of-four, так, чтобы углы компоновались как-нибудь по-другому. (Например, можно сделать так, чтобы большой мистер Роджерс
выглядывал из каждого угла квадрата.)

2.3. Символьные данные
Все составные объекты данных, которые мы до сих пор использовали, состояли, в
конечном счете, из чисел. В этом разделе мы расширяем возможности представления
нашего языка, разрешая использовать в качестве данных произвольные символы.

2.3.1. Кавычки
Раз теперь нам можно формировать составные данные, используя символы, мы можем
пользоваться списками вроде
(a b c d)
(23 45 17)
((Norah 12) (Molly 9) (Anna 7) (Lauren 6) (Charlotte 3))

Списки, содержащие символы, могут выглядеть в точности как выражения нашего языка:
(* (+ 23 45) (+ x 9))
(define (fact n) (if (= n 1) 1 (* n (fact (- n 1)))))

Чтобы работать с символами, нам в языке нужен новый элемент: способность закавычить (quote) объект данных. Допустим, нам хочется построить список (a b). Этого
нельзя добиться через (list a b), поскольку это выражение строит список из значений символов a и b, а не из них самих. Этот вопрос хорошо изучен по отношению к
естественным языкам, где слова и предложения могут рассматриваться либо как семантические единицы, либо как строки символов (синтаксические единицы). В естественных языках обычно используют кавычки, чтобы обозначить, что слово или предложение

2.3. Символьные данные

147

нужно рассматривать буквально как строку символов. Например, первая буква «Джона» — разумеется, «Д». Если мы говорим кому-то «скажите, как Вас зовут», мы ожидаем
услышать имя этого человека. Если же мы говорим кому-то «скажите “как Вас зовут”»,
то мы ожидаем услышать слова «как Вас зовут». Заметьте, как, для того, чтобы описать,
что должен сказать кто-то другой, нам пришлось использовать кавычки32 .
Чтобы обозначать списки и символы, с которыми нужно обращаться как с объектами
данных, а не как с выражениями, которые нужно вычислить, мы можем следовать тому
же обычаю. Однако наш формат кавычек отличается от принятого в естественных языках
тем, что мы ставим знак кавычки (по традиции, это символ одинарной кавычки ’)
только в начале того объекта, который надо закавычить. В Scheme это сходит нам с рук,
поскольку для разделения объектов мы полагаемся на пробелы и скобки. Таким образом,
значением одинарной кавычки является требование закавычить следующий объект33 .
Теперь мы можем отличать символы от их значений:
(define a 1)
(define b 2)
(list a b)
(1 2)
(list ’a ’b)
(a b)
(list ’a b)
(a 2)

Кроме того, кавычки позволяют нам вводить составные объекты, используя обычное
представление для печати списков:34 .
32 Когда мы разрешаем в языке кавычки, это разрушает нашу способность говорить о языке в простых терминах, поскольку становится неверным, что равнозначные выражения можно подставлять друг вместо друга.
Например, три есть два плюс один, но слово «три» не есть слова «два плюс один». Кавычки являются мощным
инструментом, поскольку они дают нам способ строить выражения, которые работают с другими выражениями (как мы убедимся в главе 4, когда станем писать интерпретатор). Однако как только мы разрешаем в
языке выражения, которые говорят о других выражениях того же языка, становится очень сложно соблюдать
в каком-либо виде принцип «равное можно заменить равным». Например, если мы знаем, что утренняя и вечерняя звезда — одно и то же, то из утверждения «вечерняя звезда — это Венера» мы можем заключить, что
«утренняя звезда — это Венера». Однако если нам дано, что «Джон знает, что вечерняя звезда — это Венера»,
мы не можем заключить, что «Джон знает, что утренняя звезда — это Венера».
33 Одинарная кавычка отличается от двойной, которую мы использовали для обозначения строк, выводимых
на печать. В то время как одинарную кавычку можно использовать для обозначения списков символов, двойная
кавычка используется только со строками, состоящими из печатных знаков. Единственное, для чего такие
строки используются в нашей книге — это печать.
34 Строго говоря, то, как мы используем кавычку, нарушает общее правило, что все сложные выражения
нашего языка должны отмечаться скобками и выглядеть как списки. Мы можем восстановить эту закономерность, введя особую форму quote, которая служит тем же целям, что и кавычка. Таким образом, мы можем
печатать (quote a) вместо ’a и (quote (a b c)) вместо ’(a b c). Именно так и работает интерпретатор. Знак кавычки — это просто сокращение, означающее, что следующее выражение нужно завернуть в форму
quote и получить (quote hвыражениеi). Это важно потому, что таким образом соблюдается принцип, что
с любым выражением, которое видит интерпретатор, можно обращаться как с объектом данных. Например,
можно получить выражение (car ’(a b c)), и это будет то же самое, что и (car (quote (a b c))),
вычислив (list ’car (list ’quote ’(a b c))).

148

Глава 2. Построение абстракций с помощью данных

(car ’(a b c))
a
(cdr ’(a b c))
(b c)

Действуя в том же духе, пустой список мы можем получить, вычисляя ’(), и таким
образом избавиться от переменной nil.
Еще один примитив, который используется при работе с символами — это eq?,
который берет в качестве аргументов два символа и проверяет, совпадают ли они35 .
С помощью eq? можно реализовать полезную процедуру, называемую memq. Она
принимает два аргумента, символ и список. Если символ не содержится в списке (то
есть, не равен в смысле eq? ни одному из элементов списка), то memq возвращает ложь.
В противном случае она возвращает подсписок списка, начиная с первого вхождения
символа:
(define (memq item x)
(cond ((null? x) false)
((eq? item (car x)) x)
(else (memq item (cdr x)))))

Например, значение
(memq ’apple ’(pear banana prune))

есть ложь, в то время как значение
(memq ’apple ’(x (apple sauce) y apple pear))

есть (apple pear).
Упражнение 2.53.
Что напечатает интерпретатор в ответ на каждое из следующих выражений?
(list ’a ’b ’c)
(list (list ’george))
(cdr ’((x1 x2) (y1 y2)))
(cadr ’((x1 x2) (y1 y2)))
(pair? (car ’(a short list)))
(memq ’red ’((red shoes) (blue socks)))
(memq ’red ’(red shoes blue socks))
35 Можно считать, что два символа «совпадают», если они состоят из одних и тех же печатных знаков
в одинаковом порядке. Такое определение обходит важный вопрос, который мы пока не готовы обсуждать:
значение «одинаковости» в языке программирования. К нему мы вернемся в главе 3 (раздел 3.1.3).

2.3. Символьные данные

149

Упражнение 2.54.
Предикат equal? для двух списков возвращает истину, если они содержат одни и те же элементы
в одинаковом порядке. Например,
(equal? ’(this is a list) ’(this is a list))
истинно, но
(equal? ’(this is a list) ’(this (is a) list))
ложно. Более точно, можно определить equal? рекурсивно в терминах базового равенства символов eq?, сказав, что a равно b, если оба они символы и для них выполняется eq? либо оба они
списки и при этом верно, что (car a) равняется в смысле equal? (car b), а (cdr a) равняется в смысле equal? (cdr b). Пользуясь этой идеей, напишите equal? в виде процедуры36.
Упражнение 2.55.
Ева Лу Атор вводит при работе с интерпретатором выражение
(car ’’abracadabra)
К ее удивлению, интерпретатор печатает quote. Объясните.

2.3.2. Пример: символьное дифференцирование
Как иллюстрацию к понятию символьной обработки, а также как дополнительный
пример абстракции данных, рассмотрим построение процедуры, которая производит символьное дифференцирование алгебраических выражений. Нам хотелось бы, чтобы эта
процедура принимала в качестве аргументов алгебраическое выражение и переменную,
и чтобы она возвращала производную выражения по отношению к этой переменной. Например, если аргументами к процедуре служат ax2 + bx + c и x, процедура должна возвращать 2ax + b. Символьное дифференцирование имеет для Лиспа особое историческое
значение. Оно было одним из побудительных примеров при разработке компьютерного
языка для обработки символов. Более того, оно послужило началом линии исследований,
приведшей к разработке мощных систем для символической математической работы, которые сейчас все больше используют прикладные математики и физики.
При разработке программы для символьного дифференцирования мы будем следовать
той же самой стратегии абстракции данных, согласно которой мы действовали при разработке системы рациональных чисел в разделе 2.1.1. А именно, сначала мы разработаем
алгоритм дифференцирования, который работает с абстрактными объектами, такими как
«суммы», «произведения» и «переменные», не обращая внимания на то, как они должны
быть представлены. Только после этого мы обратимся к задаче представления.
36 На практике программисты используют equal? для сравнения не только символов, но и чисел. Числа
не считаются символами. Вопрос о том, выполняется ли eq? для двух чисел, которые равны между собой (в
смысле =), очень сильно зависит от конкретной реализации. Более правильное определение equal? (например,
то, которое входит в Scheme как элементарная процедура) должно содержать условие, что если и a, и b
являются числами, то equal? для них выполняется тогда, когда они численно равны.

150

Глава 2. Построение абстракций с помощью данных

Программа дифференцирования с абстрактными данными
Чтобы упростить задачу, мы рассмотрим простую программу символьного дифференцирования, которая работает с выражениями, построенными только при помощи операций сложения и умножения с двумя аргументами. Дифференцировать любое такое
выражение можно, применяя следующие правила редукции:
dc
= 0 для константы либо переменной, отличной от x
dx
dx
=1
dx
d(u + v)
du
dv
=
+
dx
dx dx
dv
du
d(uv)
= u( ) + v( )
dx
dx
dx
Заметим, что два последних правила по сути своей рекурсивны. То есть, чтобы получить производную суммы, нам сначала нужно получить производные слагаемых и их
сложить. Каждое из них в свою очередь может быть выражением, которое требуется
разложить на составляющие. Разбивая их на все более мелкие части, мы в конце концов
дойдем до стадии, когда все части являются либо константами, либо переменными, и их
производные будут равны либо 0, либо 1.
Чтобы воплотить эти правила в виде процедуры, мы позволим себе немного помечтать, подобно тому, как мы делали при реализации рациональных чисел. Если бы у нас
был способ представления алгебраических выражений, мы могли бы проверить, является ли выражение суммой, произведением, константой или переменной. Можно было бы
извлекать части выражений. Например, для суммы мы хотели бы уметь получать первое
и второе слагаемое. Еще нам нужно уметь составлять выражения из частей. Давайте
предположим, что у нас уже есть процедуры, которые реализуют следующие селекторы,
конструкторы и предикаты:
• (variable? e) Является ли e переменной?

• (same-variable? v1 v2) Являются ли v1 и v2 одной и той же переменной?

• (sum? e) Является ли e суммой?

• (addend e) Первое слагаемое суммы e.
• (augend e) Второе слагаемое суммы e.

• (make-sum a1 a2) Строит сумму a1 и a2.

• (product? e) Является ли e произведением?

• (multiplier e) Первый множитель произведения e.

• (multiplicand e) Второй множитель произведения e.

• (make-product m1 m2) Строит произведение m1 и m2.

При помощи этих процедур и элементарного предиката number?, который распознает числа, мы можем выразить правила дифференцирования в виде следующей процедуры:

2.3. Символьные данные

151

(define (deriv exp var)
(cond ((number? exp) 0)
((variable? exp)
(if (same-variable? exp var) 1 0))
((sum? exp)
(make-sum (deriv (addend exp) var)
(deriv (augend exp) var)))
((product? exp)
(make-sum
(make-product (multiplier exp)
(deriv (multiplicand exp) var))
(make-product (deriv (multiplier exp) var)
(multiplicand exp))))
(else
(error "неизвестный тип выражения -- DERIV" exp))))

Процедура deriv заключает в себе весь алгоритм дифференцирования. Поскольку она
выражена в терминах абстрактных данных, она будет работать, как бы мы ни представили алгебраические выражения, если только у нас будут соответствующие селекторы и
конструкторы. Именно этим вопросом нам и нужно теперь заняться.
Представление алгебраических выражений
Можно представить себе множество способов представления алгебраических выражений с помощью списковых структур. Например, можно использовать списки символов,
которые отражали бы обычную алгебраическую нотацию, так что ax + b представлялось
бы как список (a * x + b). Однако естественней всего использовать ту же скобочную
префиксную запись, с помощью которой в Лиспе представляются комбинации; то есть
представлять ax + b в виде (+ (* a x) b). Тогда наше представление данных для
задачи дифференцирования будет следующим:
• Переменные — это символы. Они распознаются элементарным предикатом symbol?:
(define (variable? x) (symbol? x))

• Две переменные одинаковы, если для представляющих их символов выполняется
eq?:
(define (same-variable? v1 v2)
(and (variable? v1) (variable? v2) (eq? v1 v2)))

• Суммы и произведения конструируются как списки:
(define (make-sum a1 a2) (list ’+ a1 a2))
(define (make-product m1 m2) (list ’* m1 m2))

• Сумма — это список, первый элемент которого символ +:

152

Глава 2. Построение абстракций с помощью данных

(define (sum? x)
(and (pair? x) (eq? (car x) ’+)))

• Первое слагаемое — это второй элемент списка, представляющего сумму:
(define (addend s) (cadr s))

• Второе слагаемое — это третий элемент списка, представляющего сумму:
(define (augend s) (caddr s))

• Произведение — это список, первый элемент которого символ *:
(define (product? x)
(and (pair? x) (eq? (car x) ’*)))

• Первый множитель — это второй элемент списка, представляющего произведение:
(define (multiplier p) (cadr p))

• Второй множитель — это третий элемент списка, представляющего произведение:
(define (multiplicand p) (caddr p))

Таким образом, нам осталось только соединить это представление с алгоритмом,
заключенным в процедуре deriv, и мы получаем работающую программу символьного
дифференцирования. Посмотрим на некоторые примеры ее поведения:
(deriv ’(+ x 3) ’x)
(+ 1 0)
(deriv ’(* x y) ’x)
(+ (* x 0) (* 1 y))
(deriv ’(* (* x y) (+ x 3)) ’x)
(+ (* (* x y) (+ 1 0))
(* (+ (* x 0) (* 1 y))
(+ x 3)))

Ответы, которые выдает программа, правильны; однако их нужно упрощать. Верно, что
d(xy)
= x·0+1·y
dx
но нам хотелось бы, чтобы программа знала, что x·0 = 0, 1·y = y, а 0+y = y. Ответом на
второй пример должно быть просто y. Как видно из третьего примера, при усложнении
выражений упрощение превращается в серьезную проблему.
Наши теперешние затруднения очень похожи на те, с которыми мы столкнулись при
реализации рациональных чисел: мы не привели ответы к простейшей форме. Чтобы
произвести приведение рациональных чисел, нам потребовалось изменить только конструкторы и селекторы в нашей реализации. Здесь мы можем применить подобную же

2.3. Символьные данные

153

стратегию. Процедуру deriv мы не будем изменять вовсе. Вместо этого мы изменим
make-sum так, что если оба слагаемых являются числами, она их сложит и вернет их
сумму. Кроме того, если одно из слагаемых равно 0, то make-sum вернет другое.
(define (make-sum a1 a2)
(cond ((=number? a1 0) a2)
((=number? a2 0) a1)
((and (number? a1) (number? a2)) (+ a1 a2))
(else (list ’+ a1 a2))))

Здесь используется процедура =number?, которая проверяет, не равно ли выражение
определенному числу:
(define (=number? exp num)
(and (number? exp) (= exp num)))

Подобным же образом мы изменим и make-product, так. чтобы встроить в него правила, что нечто, умноженное на 0, есть 0, а умноженное на 1 равно самому себе:
(define (make-product m1 m2)
(cond ((or (=number? m1 0) (=number? m2 0)) 0)
((=number? m1 1) m2)
((=number? m2 1) m1)
((and (number? m1) (number? m2)) (* m1 m2))
(else (list ’* m1 m2))))

Вот как эта версия работает на наших трех примерах:
(deriv ’(+ x 3) ’x)
1
(deriv ’(* x y) ’x)
y
(deriv ’(* (* x y) (+ x 3)) ’x)
(+ (* x y) (* y (+ x 3)))

Хотя это заметное улучшение, третий пример показывает, что нужно многое еще сделать, прежде чем мы получим программу, приводящую выражения к форме, которую
мы согласимся считать «простейшей». Задача алгебраического упрощения сложна, среди
прочего, еще и потому, что форма, которая является простейшей для одних целей, может
таковой не являться для других.
Упражнение 2.56.
Покажите, как расширить простейшую программу дифференцирования так, чтобы она воспринимала больше разных типов выражений. Например, реализуйте правило взятия производной
d(un )
du
= nun−1 ( )
dx
dx
добавив еще одну проверку к программе deriv и определив соответствующие процедуры exponentiation?,
base, exponent и make-exponentiation (обозначать возведение в степень можно символом

154

Глава 2. Построение абстракций с помощью данных

**). Встройте правила, что любое выражение, возведенное в степень 0, дает 1, а возведенное в
степень 1 равно самому себе.
Упражнение 2.57.
Расширьте программу дифференцирования так, чтобы она работала с суммами и произведениями
любого (больше двух) количества термов. Тогда последний из приведенных выше примеров мог бы
быть записан как
(deriv ’(* x y (+ x 3)) ’x)
Попытайтесь сделать это, изменяя только представление сумм и произведений, не трогая процедуру deriv. Тогда, например, процедура addend будет возвращать первое слагаемое суммы, а
augend сумму остальных.
Упражнение 2.58.
Предположим, что нам захотелось изменить программу дифференцирования так, чтобы она работала с обычной математической нотацией, где + и * не префиксные, а инфиксные операции.
Поскольку программа взятия производных определена в терминах абстрактных данных, мы можем
изменять представление выражений, с которыми она работает, меняя только предикаты, селекторы и конструкторы, определяющие представление алгебраических выражений, с которыми должен
работать дифференциатор.
а. Покажите, как это сделать так, чтобы брать производные от выражений, представленных в
инфиксной форме, например (x + (3 * (x + (y + 2)))). Для упрощения задачи предположите, что + и * всегда принимают по два аргумента, и что в выражении расставлены все скобки.
б. Задача становится существенно сложней, если мы разрешаем стандартную алгебраическую
нотацию, например (x + 3 * (x + y + 2)), которая опускает ненужные скобки и предполагает, что умножение выполняется раньше, чем сложение. Можете ли Вы разработать соответствующие предикаты, селекторы и конструкторы для этой нотации так, чтобы наша программа взятия
производных продолжала работать?

2.3.3. Пример: представление множеств
В предыдущих примерах мы построили представления для двух типов составных
объектов: для рациональных чисел и для алгебраических выражений. В одном из этих
примеров перед нами стоял выбор, упрощать ли выражение при его конструировании
или при обращении; в остальном же выбор представления наших структур через списки
был простым делом. Когда мы обращаемся к представлению множеств, выбор представления не так очевиден. Здесь существует несколько возможных представлений, и они
значительно отличаются друг от друга в нескольких аспектах.
Говоря неформально, множество есть просто набор различных объектов. Чтобы дать
ему более точное определение, можно использовать метод абстракции данных. А именно, мы определяем «множество», указывая операции, которые можно производить над
множествами. Это операции union-set (объединение), intersection-set (пересечение), element-of-set? (проверка на принадлежность) и adjoin-set (добавление
элемента). Element-of-set? — это предикат, который определяет, является ли данный объект элементом множества. Adjoin-set принимает как аргументы объект и
множество, и возвращает множество, которое содержит все элементы исходного множества плюс добавленный элемент. Union-set вычисляет объединение двух множеств,

2.3. Символьные данные

155

то есть множество, содержащее те элементы, которые присутствуют хотя бы в одном из
аргументов. Intersection-set вычисляет пересечение двух множеств, то есть множество, которое содержит только те элементы, которые присутствуют в обоих аргументах.
С точки зрения абстракции данных, мы имеем право взять любое представление, позволяющее нам использовать эти операции способом, который согласуется с вышеуказанной
интерпретацией37.
Множества как неупорядоченные списки
Можно представить множество как список, в котором ни один элемент не содержится
более одного раза. Пустое множество представляется пустым списком. При таком представлении element-of-set? подобен процедуре memq из раздела 2.3.1. Она использует
не eq?, а equal?, так что элементы множества не обязаны быть символами:
(define (element-of-set? x set)
(cond ((null? set) false)
((equal? x (car set)) true)
(else (element-of-set? x (cdr set)))))

Используя эту процедуру, мы можем написать adjoin-set. Если объект, который требуется добавить, уже принадлежит множеству, мы просто возвращаем исходное множество. В противном случае мы используем cons, чтобы добавить объект к списку.
представляющему множество:
(define (adjoin-set x set)
(if (element-of-set? x set)
set
(cons x set)))

Для intersection-set можно использовать рекурсивную стратегию. Если мы знаем,
как получить пересечение set2 и cdr от set1, нам нужно только понять, надо ли
добавить к нему car от set1. Это зависит от того, принадлежит ли (car set1) еще
и set2. Получается такая процедура:
(define (intersection-set set1 set2)
(cond ((or (null? set1) (null? set2)) ’())
((element-of-set? (car set1) set2)
(cons (car set1)
(intersection-set (cdr set1) set2)))
(else (intersection-set (cdr set1) set2))))
37 Если нам хочется быть более формальными, мы можем определить «соответствие вышеуказанной интерпретации» как условие, что операции удовлетворяют некоторому набору правил вроде следующих:

• Для любого множества S и любого объекта x, (element-of-set? x (adjoin-set x S)) истинно
(неформально: «добавление объекта к множеству дает множество, содержащее этот объект»).
• Для любых двух множеств S и T и любого объекта x, (element-of-set? x (union-set S T))
равно (or (element-of-set? x S) (element-of-set? x T)) (неформально: «элементы (union-set
S T) — это те элементы, которые принадлежат либо S, либо T»).
• Для любого объекта x, (element-of-set? x ’()) ложно (неформально: «ни один объект не принадлежит пустому множеству»).

156

Глава 2. Построение абстракций с помощью данных

Один из вопросов, которые должны нас заботить при разработке реализации — эффективность. Рассмотрим число шагов, которые требуют наши операции над множествами. Поскольку все они используют element-of-set?, скорость этой операции
оказывает большое влияние на скорость реализации в целом. Теперь заметим, что для
того, чтобы проверить, является ли объект элементом множества, процедуре elementof-set? может потребоваться просмотреть весь список. (В худшем случае оказывается,
что объекта в списке нет.) Следовательно, если в множестве n элементов, element-ofset? может затратить до n шагов. Таким образом, число требуемых шагов растет как
Θ(n). Число шагов, требуемых adjoin-set, которая эту операцию использует, также
растет как Θ(n). Для intersection-set, которая проделывает element-of-set?
для каждого элемента set1, число требуемых шагов растет как произведение размеров
исходных множеств, или Θ(n2 ) для двух множеств размера n. То же будет верно и для
union-set.
Упражнение 2.59.
Реализуйте операцию union-set для представления множеств в виде неупорядоченных списков.
Упражнение 2.60.
Мы указали, что множество представляется как список без повторяющихся элементов. Допустим теперь, что мы разрешаем повторяющиеся элементы. Например, множество {1, 2, 3} могло бы
быть представлено как список (2 3 2 1 3 2 2). Разработайте процедуры element-of-set?,
adjoin-set, union-set и intersection-set, которые бы работали с таким представлением.
Как соотносится эффективность этих операций с эффективностью соответствующих процедур для
представления без повторений? Существуют ли приложения, в которых Вы бы использовали скорее
это представление, чем представление без повторений?

Множества как упорядоченные списки
Один из способов ускорить операции над множествами состоит в том, чтобы изменить
представление таким образом, чтобы элементы множества перечислялись в порядке возрастания. Для этого нам потребуется способ сравнения объектов, так, чтобы можно было
сказать, какой из них больше. Например, символы мы могли бы сравнивать лексикографически, или же мы могли бы найти какой-нибудь способ ставить каждому объекту в
соответствие некоторое уникальное число и затем сравнивать объекты путем сравнения
соответствующих чисел. Чтобы упростить обсуждение, мы рассмотрим только случай,
когда элементами множества являются числа, так что мы сможем сравнивать элементы
при помощи > и x (entry set))
(element-of-set? x (right-branch set)))))

Добавление элемента к множеству реализуется похожим образом и также требует Θ(log n) шагов. Чтобы добавить объект x, мы сравниваем его с входом вершины и
определяем, должны ли мы добавить x к левой или правой ветви, а добавив x к соответствующей ветви, мы соединяем результат с изначальным входом и второй ветвью.
Если x равен входу, мы просто возвращаем вершину. Если нам требуется добавить x к
пустому дереву, мы порождаем дерево, которое содержит x на входе и пустые левое и
правое поддеревья. Вот процедура:
(define (adjoin-set x set)
(cond ((null? set) (make-tree x ’() ’()))
((= x (entry set)) set)
((< x (entry set))
(make-tree (entry set)
(adjoin-set x (left-branch set))
(right-branch set)))
((> x (entry set))
(make-tree (entry set)
(left-branch set)
(adjoin-set x (right-branch set))))))
39 Мы представляем множества при помощи деревьев, а деревья при помощи списков — получается абстракция данных на основе другой абстракции данных. Процедуры entry, left-branch, right-branch и
make-tree мы можем рассматривать как способ изолировать абстракцию «бинарное дерево» от конкретного
способа, которым мы желаем представить такое дерево в виде списковой структуры.

Глава 2. Построение абстракций с помощью данных

160
1
2

3
4
5
6
7
Рис. 2.17. Несбалансированное дерево, порожденное последовательным присоединением
элементов от 1 до 7.

Утверждение, что поиск в дереве можно осуществить за логарифмическое
число шагов, основывается на предположении, что дерево «сбалансировано»,
то есть что левое и правое его поддеревья содержат приблизительно одинаковое
число
элементов,
так
что
каждое
поддерево
содержит
приблизительно
половину элементов своего родителя. Но как нам добиться того, чтобы те
деревья, которые мы строим, были сбалансированы? Даже если мы начинаем со
сбалансированного дерева, добавление элементов при помощи adjoin-set может дать
несбалансированный результат. Поскольку позиция нового добавляемого элемента зависит от того, как этот элемент соотносится с объектами, уже содержащимися в
множестве, мы имеем право ожидать, что если мы будем добавлять элементы «случайным образом», в среднем дерево будет получаться сбалансированным.
Однако такой гарантии у нас нет. Например, если мы начнем с пустого
множества и будем добавлять по очереди числа от 1 до 7, то получится весьма
несбалансированное дерево, показанное на рисунке 2.17. В этом дереве все
левые поддеревья пусты, так что нет никакого преимущества по сравнению с
простым упорядоченным списком. Одним из способов решения этой проблемы было бы
определение операции, которая переводит произвольное дерево в сбалансированное с теми же элементами. Тогда мы сможем проводить преобразование через каждые несколько
операций adjoin-set, чтобы поддерживать множество в сбалансированном виде. Есть
и другие способы решения этой задачи. Большая часть из них связана с разработкой новых структур данных, для которых и поиск, и вставка могут производиться за Θ(log n)
шагов40 .
40 Примерами таких структур могут служить B-деревья (B-trees) и красно-черные деревья (red-black trees).
Существует обширная литература по структурам данных, посвященная этой задаче. См. Cormen, Leiserson,
and Rivest 1990.

2.3. Символьные данные

161

Упражнение 2.63.
Каждая из следующих двух процедур преобразует дерево в список.
(define (tree->list-1 tree)
(if (null? tree)
’()
(append (tree->list-1 (left-branch tree))
(cons (entry tree)
(tree->list-1 (right-branch tree))))))
(define (tree->list-2 tree)
(define (copy-to-list tree result-list)
(if (null? tree)
result-list
(copy-to-list (left-branch tree)
(cons (entry tree)
(copy-to-list (right-branch tree)
result-list)))))
(copy-to-list tree ’()))
а. Для всякого ли дерева эти процедуры дают одинаковый результат? Если нет, то как их
результаты различаются? Какой результат дают эти две процедуры для деревьев с рисунка 2.16?
б. Одинаков ли порядок роста этих процедур по отношению к числу шагов, требуемых для
преобразования сбалансированного дерева с n элементами в список? Если нет, которая из них
растет медленнее?
Упражнение 2.64.
Следующая процедура list->tree преобразует упорядоченный список в сбалансированное бинарное дерево. Вспомогательная процедура partial-tree принимает в качестве аргументов целое число n и список по крайней мере из n элементов, и строит сбалансированное дерево из первых
n элементов дерева. Результат, который возвращает partial-tree, — это пара (построенная через cons), car которой есть построенное дерево, а cdr — список элементов, не включенных в
дерево.
(define (list->tree elements)
(car (partial-tree elements (length elements))))
(define (partial-tree elts n)
(if (= n 0)
(cons ’() elts)
(let ((left-size (quotient (- n 1) 2)))
(let ((left-result (partial-tree elts left-size)))
(let ((left-tree (car left-result))
(non-left-elts (cdr left-result))
(right-size (- n (+ left-size 1))))
(let ((this-entry (car non-left-elts))
(right-result (partial-tree (cdr non-left-elts)
right-size)))
(let ((right-tree (car right-result))
(remaining-elts (cdr right-result)))
(cons (make-tree this-entry left-tree right-tree)
remaining-elts))))))))

Глава 2. Построение абстракций с помощью данных

162

а. Дайте краткое описание, как можно более ясно объясняющее работу partialtree. Нарисуйте дерево, которое partial-tree строит из списка (1 3 5 7 9 11)
б. Каков порядок роста по отношению к числу шагов, которые требуются процедуре
list->tree для преобразования дерева из n элементов?
Упражнение 2.65.
Используя результаты упражнений 2.63 и 2.64, постройте реализации union-set и intersection-set порядка Θ(n) для множеств, реализованных как (сбалансированные) бинарные деревья41 .

Множества и поиск информации
Мы рассмотрели способы представления множеств при помощи списков и увидели,
как выбор представления для объектов данных может сильно влиять на производительность программ, использующих эти данные. Еще одной причиной нашего внимания к
множествам было то, что описанные здесь методы снова и снова возникают в приложениях, связанных с поиском данных.
Рассмотрим базу данных, содержащую большое количество записей, например, сведения о кадрах какой-нибудь компании или о транзакциях в торговой системе. Как
правило, системы управления данными много времени проводят, занимаясь поиском и
модификацией данных в записях; следовательно, им нужны эффективные методы доступа к записям. Для этого часть каждой записи выделяется как идентифицирующий ключ
(key). Ключом может служить что угодно, что однозначно определяет запись. В случае
записей о кадрах это может быть номер карточки сотрудника. Для торговой системы
это может быть номер транзакции. Каков бы ни был ключ, когда мы определяем запись в виде структуры данных, нам нужно указать процедуру выборки ключа, которая
возвращает ключ, связанный с данной записью.
Пусть мы представляем базу данных как множество записей. Чтобы получить запись
с данным ключом, мы используем процедуру lookup, которая принимает как аргументы
ключ и базу данных и возвращает запись, содержащую указанный ключ, либо ложь,
если такой записи нет. Lookup реализуется почти так же, как element-of-set?.
Например, если множество записей реализуется как неупорядоченный список, мы могли
бы написать
(define (lookup given-key set-of-records)
(cond ((null? set-of-records) false)
((equal? given-key (key (car set-of-records)))
(car set-of-records))
(else (lookup given-key (cdr set-of-records)))))

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

2.63–2.65 мы обязаны Полу Хилфингеру.

2.3. Символьные данные

163

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

2.3.4. Пример: деревья кодирования по Хаффману
Этот раздел дает возможность попрактиковаться в использовании списковых структур и абстракции данных для работы с множествами и деревьями. Они применяются
к методам представления данных как последовательностей из единиц и нулей (битов).
Например, стандартный код ASCII, который используется для представления текста в
компьютерах, кодирует каждый символ как последовательность из семи бит. Семь бит
позволяют нам обозначить 27 , то есть 128 различных символов. В общем случае, если
нам требуется различать n символов, нам потребуется log2 n бит для каждого символа.
Если все наши сообщения составлены из восьми символов A, B, C, D, E, F, G, и H, мы
можем использовать код с тремя битами для каждого символа, например
A
B

000
001

C
D

010
011

E
F

100
101

G
H

110
111

С таким кодом, сообщение
BACADAEAFABBAAAGAH
кодируется как строка из 54 бит
001000010000011000100000101000001001000000000110000111
Такие коды, как ASCII и наш код от A до H, известны под названием кодов с фиксированной длиной, поскольку каждый символ сообщения они представляют с помощью
одного и того же числа битов. Иногда полезно использовать и коды с переменной длиной
(variable-length), в которых различные символы могут представляться различным числом
битов. Например, азбука Морзе не для всех букв алфавита использует одинаковое число
точек и тире. В частности, E, наиболее частая (в английском) буква, представляется с
помощью одной точки. В общем случае, если наши сообщения таковы, что некоторые
символы встречаются очень часто, а некоторые очень редко, то мы можем кодировать
свои данные более эффективно (т. е. с помощью меньшего числа битов на сообщение),
если более частым символам мы назначим более короткие коды. Рассмотрим следующий
код для букв с A по H:
A
B

0
100

C
D

1010
1011

E
F

1100
1101

G
H

1110
1111

С таким кодом то же самое сообщение преобразуется в строку
100010100101101100011010100100000111001111
В этой строке 42 бита, так что она экономит более 20% места по сравнению с приведенным выше кодом с фиксированной длиной.

164

Глава 2. Построение абстракций с помощью данных

Одна из сложностей при работе с кодом с переменной длиной состоит в том, чтобы
узнать, когда при чтении последовательности единиц и нулей достигнут конец символа.
В азбуке Морзе эта проблема решается при помощи специального кода-разделителя
(separator code) (в данном случае паузы) после последовательности точек и тире для
каждой буквы. Другое решение состоит в том, чтобы построить систему кодирования
так, чтобы никакой полный код символа не совпадал с началом (или префиксом) кода
никакого другого символа. Такой код называется префиксным (prefix). В вышеприведенном примере A кодируется 0, а B 100, так что никакой другой символ не может иметь
код, который начинается на 0 или 100.
В общем случае можно добиться существенной экономии, если использовать коды
с переменной длиной, использующие относительные частоты символов в подлежащих
кодированию сообщениях. Одна из схем такого кодирования называется кодированием
по Хаффману, в честь своего изобретателя, Дэвида Хаффмана. Код Хаффмана может
быть представлен как бинарное дерево, на листьях которого лежат кодируемые символы.
В каждом нетерминальном узле находится множество символов с тех листьев, которые
лежат под данным узлом. Кроме того, каждому символу на листе дерева присваивается
вес (представляющий собой относительную частоту), а каждый нетерминальный узел
имеет вес, который равняется сумме весов листьев, лежащих под данным узлом. Веса
не используются в процессе кодирования и декодирования. Ниже мы увидим, как они
оказываются полезными при построении дерева.
Рисунок 2.18 изображает дерево Хаффмана для кода от A до H, показанного выше.
Веса в вершинах дерева указывают, что дерево строилось для сообщений, где A встречается с относительной частотой 8, B с относительной частотой 3, а все остальные буквы
с относительной частотой 1.
Имея дерево Хаффмана, можно найти код любого символа, если начать с корня и
двигаться вниз до тех пор, пока не будет достигнута концевая вершина, содержащая
этот символ. Каждый раз, как мы спускаемся по левой ветви, мы добавляем 0 к коду, а
спускаясь по правой ветви, добавляем 1. (Мы решаем, по какой ветке двигаться, проверяя, не является ли одна из веток концевой вершиной, а также содержит ли множество
при вершине символ, который мы ищем.) Например, начиная с корня на картине 2.18, мы
попадаем в концевую вершину D, сворачивая на правую дорогу, затем на левую, затем
на правую, затем, наконец, снова на правую ветвь; следовательно, код для D — 1011.
Чтобы раскодировать последовательность битов при помощи дерева Хаффмана, мы
начинаем с корня и просматриваем один за другим биты в последовательности, чтобы
решить, нужно ли нам спускаться по левой или по правой ветви. Каждый раз, как мы
добираемся до листовой вершины, мы порождаем новый символ сообщения и возвращаемся к вершине дерева, чтобы найти следующий символ. Например, пусть нам дано
дерево, изображенное на рисунке, и последовательность 10001010. Начиная от корня,
мы идем по правой ветви (поскольку первый бит в строке 1), затем по левой (поскольку
второй бит 0), затем опять по левой (поскольку и третий бит 0). Здесь мы попадаем в
лист, соответствующий B, так что первый символ декодируемого сообщения — B. Мы
снова начинаем от корня и идем налево, поскольку следующий бит строки 0. Тут мы попадаем в лист, изображающий символ A. Мы опять начинаем от корня с остатком строки
1010, двигаемся направо, налево, направо, налево и приходим в C. Таким образом, все
сообщение было BAC.

2.3. Символьные данные

165

{A B C D E F G H} 17
{B C D E F G H} 9

A 8

{B C D} 5
{C D} 2

B 3
C 1

D 1

{E F G H} 4
{E F} 2
E 1

{G H} 2

F 1
G 1

H 1

Рис. 2.18. Дерево кодирования по Хаффману.

Порождение деревьев Хаффмана
Если нам дан «алфавит» символов и их относительные частоты, как мы можем породить «наилучший» код? (Другими словами, какое дерево будет кодировать сообщения
при помощи наименьшего количества битов?) Хаффман дал алгоритм для решения этой
задачи и показал, что получаемый этим алгоритмом код — действительно наилучший код
с переменной длиной для сообщений, где относительная частота символов соответствует частотам, для которых код строился. Здесь мы не будем доказывать оптимальность
кодов Хаффмана, но покажем, как эти коды строятся42 .
Алгоритм порождения дерева Хаффмана весьма прост. Идея состоит в том, чтобы
упорядочить дерево так, чтобы символы с наименьшей частотой оказались дальше всего
от корня. Начнем с множества терминальных вершин, содержащих символы и их частоты, как указано в исходных данных, из которых нам надо построить дерево. Теперь
найдем два листа с наименьшими весами и сольем их, получая вершину, у которой
предыдущие две являются левым и правым потомками. Вес новой вершины равен сумме весов ее ветвей. Исключим два листа из исходного множества и заменим их новой
вершиной. Продолжим этот процесс. На каждом шаге будем сливать две вершины с самыми низкими весами, исключая их из множества и заменяя вершиной, для которой они
являются левой и правой ветвями. Этот процесс заканчивается, когда остается только
одна вершина, которая и является корнем всего дерева. Вот как было порождено дерево
Хаффмана на рисунке 2.18:
42 Обсуждение

математических свойств кодов Хаффмана можно найти в Hamming 1980.

Глава 2. Построение абстракций с помощью данных

166
Исходный набор листьев
Слияние
Слияние
Слияние
Слияние
Слияние
Слияние
Окончательное слияние

{(A 8) (B 3) (C 1) (D 1) (E 1) (F 1) (G 1) (H 1)}
{(A 8) (B 3) ({C D} 2) (E 1) (F 1) (G 1) (H 1)}
{(A 8) (B 3) ({C D} 2) ({E F} 2) (G 1) (H 1)}
{(A 8) (B 3) ({C D} 2) ({E F} 2) ({G H} 2)}
{(A 8) (B 3) ({C D} 2) ({E F G H} 4)}
{(A 8) ({B C D} 5) ({E F G H} 4)}
{(A 8) ({B C D E F G H} 9)}
{({A B C D E F G H} 17)}

Алгоритм не всегда приводит к построению единственно возможного дерева, поскольку на каждом шаге выбор вершин с наименьшим весом может быть не единственным.
Выбор порядка, в котором будут сливаться две вершины (то есть, какая из них будет
левым, а какая правым поддеревом) также произволен.
Представление деревьев Хаффмана
В следующих упражнениях мы будем работать с системой, которая использует деревья Хаффмана для кодирования и декодирования сообщений и порождает деревья Хаффмана в соответствии с вышеописанным алгоритмом. Начнем мы с обсуждения того, как
представляются деревья.
Листья дерева представляются в виде списка, состоящего из символа leaf (лист),
символа, содержащегося в листе, и веса:
(define (make-leaf symbol weight)
(list ’leaf symbol weight))
(define (leaf? object)
(eq? (car object) ’leaf))
(define (symbol-leaf x) (cadr x))
(define (weight-leaf x) (caddr x))

Дерево в общем случае будет списком из левой ветви, правой ветви, множества символов и веса. Множество символов будет просто их списком, а не каким-то более сложным представлением. Когда мы порождаем дерево слиянием двух вершин, мы получаем вес дерева как сумму весов этих вершин, а множество символов как объединение
множеств их символов. Поскольку наши множества представлены в виде списка, мы
можем породить объединение при помощи процедуры append, определенной нами в
разделе 2.2.1:
(define (make-code-tree left right)
(list left
right
(append (symbols left) (symbols right))
(+ (weight left) (weight right))))

Если мы порождаем дерево таким образом, то у нас будут следующие селекторы:

2.3. Символьные данные

167

(define (left-branch tree) (car tree))
(define (right-branch tree) (cadr tree))
(define (symbols tree)
(if (leaf? tree)
(list (symbol-leaf tree))
(caddr tree)))
(define (weight tree)
(if (leaf? tree)
(weight-leaf tree)
(cadddr tree)))

Процедуры symbols и weight должны вести себя несколько по-разному в зависимости
от того, вызваны они для листа или для дерева общего вида. Это простые примеры
обобщенных процедур (generic procedures) (процедур, которые способны работать более,
чем с одним типом данных), о которых мы будем говорить намного более подробно в
разделах 2.4 и 2.5.
Процедура декодирования
Следующая процедура реализует алгоритм декодирования. В качестве аргументов она
принимает список из единиц и нулей, а также дерево Хаффмана.
(define (decode bits tree)
(define (decode-1 bits current-branch)
(if (null? bits)
’()
(let ((next-branch
(choose-branch (car bits) current-branch)))
(if (leaf? next-branch)
(cons (symbol-leaf next-branch)
(decode-1 (cdr bits) tree))
(decode-1 (cdr bits) next-branch)))))
(decode-1 bits tree))
(define (choose-branch bit branch)
(cond ((= bit 0) (left-branch branch))
((= bit 1) (right-branch branch))
(else (error "плохой бит -- CHOOSE-BRANCH" bit))))

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

168

Глава 2. Построение абстракций с помощью данных

Множества взвешенных элементов
В нашем представлении деревьев каждая нетерминальная вершина содержит множество символов, которое мы представили как простой список. Однако алгоритм порождения дерева, который мы обсуждали выше, требует, чтобы мы работали еще и с
множествами листьев и деревьев, последовательно сливая два наименьших элемента.
Поскольку нам нужно будет раз за разом находить наименьший элемент множества,
удобно для такого множества использовать упорядоченное представление.
Мы представим множество листьев и деревьев как список элементов, упорядоченный
по весу в возрастающем порядке. Следующая процедура adjoinset для построения
множеств подобна той, которая описана в упражнении 2.61; однако элементы сравниваются по своим весам, и никогда не бывает так, что добавляемый элемент уже содержится
в множестве.
(define (adjoin-set x set)
(cond ((null? set) (list x))
((< (weight x) (weight (car set))) (cons x set))
(else (cons (car set)
(adjoin-set x (cdr set))))))

Следующая процедура принимает список пар вида символ–частота, например ((A
4) (B 2) (C 1) (D 1)), и порождает исходное упорядоченное множество листьев,
готовое к слиянию по алгоритму Хаффмана:
(define (make-leaf-set pairs)
(if (null? pairs)
’()
(let ((pair (car pairs)))
(adjoin-set (make-leaf (car pair)
(cadr pair))
(make-leaf-set (cdr pairs))))))

Упражнение 2.67.
Пусть нам даны дерево кодирования и пример сообщения:
(define sample-tree
(make-code-tree (make-leaf ’A 4)
(make-code-tree
(make-leaf ’B 2)
(make-code-tree (make-leaf ’D 1)
(make-leaf ’C 1)))))
(define sample-message ’(0 1 1 0 0 1 0 1 0 1 1 1 0))
Раскодируйте сообщение при помощи процедуры decode.
Упражнение 2.68.
Процедура encode получает в качестве аргументов сообщение и дерево, и порождает список
битов, который представляет закодированное сообщение.

2.3. Символьные данные

169

(define (encode message tree)
(if (null? message)
’()
(append (encode-symbol (car message) tree)
(encode (cdr message) tree))))
Encode-symbol — процедура, которую Вы должны написать, возвращает список битов, который кодирует данный символ в соответствии с заданным деревом. Вы должны спроектировать
encode-symbol так, чтобы она сообщала об ошибке, если символ вообще не содержится в дереве.
Проверьте свою процедуру, закодировав тот результат, который Вы получили в упражнении 2.67,
с деревом-примером и проверив, совпадает ли то, что получаете Вы, с исходным сообщением.
Упражнение 2.69.
Следующая процедура берет в качестве аргумента список пар вида символ-частота (где ни один
символ не встречается более, чем в одной паре) и порождает дерево кодирования по Хаффману в
соответствии с алгоритмом Хаффмана.
(define (generate-huffman-tree pairs)
(successive-merge (make-leaf-set pairs)))
Приведенная выше процедура make-leaf-set преобразует список пар в упорядоченное множество пар. Вам нужно написать процедуру successive-merge, которая при помощи make-codetree сливает наиболее легкие элементы множества, пока не останется только один элемент, который и представляет собой требуемое дерево Хаффмана. (Эта процедура устроена немного хитро, но
она не такая уж сложная. Если Вы видите, что строите сложную процедуру, значит, почти наверняка Вы делаете что-то не то. Можно извлечь немалое преимущество из того, что мы используем
упорядоченное представление для множеств.)
Упражнение 2.70.
Нижеприведенный алфавит из восьми символов с соответствующими им относительными частотами был разработан, чтобы эффективно кодировать слова рок-песен 1950-х годов. (Обратите
внимание, что «символы» «алфавита» не обязаны быть отдельными буквами.)
A
BOOM
GET
JOB

2
1
2
2

NA
SHA
YIP
WAH

16
3
9
1

При помощи generate-huffman-tree (упр. 2.69) породите соответствующее дерево Хаффмана,
и с помощью encode закодируйте следующее сообщение:
Get a job
Sha na na na na na na na na
Get a job
Sha na na na na na na na na
Wah yip yip yip yip yip yip yip yip yip
Sha boom
Сколько битов потребовалось для кодирования? Каково наименьшее число битов, которое потребовалось бы для кодирования этой песни, если использовать код с фиксированной длиной для
алфавита из восьми символов?

170

Глава 2. Построение абстракций с помощью данных

Упражнение 2.71.
Допустим, у нас есть дерево Хаффмана для алфавита из n символов, и относительные частоты
символов равны 1, 2, 4, . . . , 2n−1 . Изобразите дерево для n = 5; для n = 10. Сколько битов в таком
дереве (для произвольного n) требуется, чтобы закодировать самый частый символ? Самый редкий
символ?
Упражнение 2.72.
Рассмотрим процедуру кодирования, которую Вы разработали в упражнении 2.68. Каков порядок
роста в терминах количества шагов, необходимых для кодирования символа? Не забудьте включить число шагов, требуемых для поиска символа в каждой следующей вершине. Ответить на
этот вопрос в общем случае сложно. Рассмотрите особый случай, когда относительные частоты
символов таковы, как описано в упражнении 2.71, и найдите порядок роста (как функцию от n)
числа шагов, необходимых, чтобы закодировать самый частый и самый редкий символ алфавита.

2.4. Множественные представления для абстрактных данных
В предыдущих разделах мы описали абстракцию данных, методологию, позволяющую
структурировать системы таким образом, что бо́льшую часть программы можно специфицировать независимо от решений, которые принимаются при реализации объектов,
обрабатываемых программой. Например, в разделе 2.1.1 мы узнали, как отделить задачу проектирования программы, которая пользуется рациональными числами, от задачи
реализации рациональных чисел через элементарные механизмы построения составных
данных в компьютерном языке. Главная идея состояла в возведении барьера абстракции, — в данном случае, селекторов и конструкторов для рациональных чисел (makerat, numer, denom), — который отделяет то, как рациональные числа используются,
от их внутреннего представления через списковые структуры. Подобный же барьер абстракции отделяет детали процедур, реализующих рациональную арифметику (add-rat,
sub-rat, mul-rat и div-rat), от «высокоуровневых» процедур, которые используют
рациональные числа. Получившаяся программа имеет структуру, показанную на рис. 2.1.
Такие барьеры абстракции — мощное средство управления сложностью проекта. Изолируя внутренние представления объектов данных, нам удается разделить задачу построения большой программы на меньшие задачи, которые можно решать независимо друг
от друга. Однако такой тип абстракции данных еще недостаточно мощен, поскольку не
всегда имеет смысл говорить о «внутреннем представлении» объекта данных.
Например, может оказаться более одного удобного представления для объекта данных, и мы можем захотеть проектировать системы, которые способны работать с множественными представлениями. В качестве простого примера, комплексные числа можно
представить двумя почти эквивалентными способами: в декартовой форме (действительная и мнимая часть) и в полярной форме (модуль и аргумент). Иногда лучше подходит
декартова форма, а иногда полярная. В сущности, вполне возможно представить себе
систему, в которой комплексные числа представляются обоими способами, а процедурыоперации над комплексным числами способны работать с любым представлением.
Еще важнее то, что часто программные системы разрабатываются большим количеством людей в течение долгого времени, в соответствии с требованиями, которые также

2.4. Множественные представления для абстрактных данных

171

Программы, использующие комплексные числа
add-complex sub-complex mul-complex div-complex
Пакет комплексной арифметики
Декартово
Полярное
представление
представление
Списковая структура
Рис. 2.19. Барьеры абстракции данных в системе работы с комплексными числами.

со временем меняются. В такой ситуации просто невозможно заранее всем договориться
о выборе представления данных. Так что в дополнение к барьерам абстракции данных,
которые отделяют представление данных от их использования, нам нужны барьеры абстракции, которые отделяют друг от друга различные проектные решения и позволяют
различным решениям сосуществовать в рамках одной программы. Более того, поскольку часто большие программы создаются путем комбинирования существующих модулей,
созданных независимо друг от друга, нам требуются соглашения, которые позволяли бы
программистам добавлять модули к большим системам аддитивно (additively), то есть
без перепроектирования и переписывания этих модулей.
В этом разделе мы научимся работать с данными, которые могут быть представлены в
разных частях программы различными способами. Это требует построения обобщенных
процедур (generic procedures) — процедур, работающих с данными, которые могут быть
представлены более чем одним способом. Наш основной метод построения обобщенных
процедур будет состоять в том, чтобы работать в терминах объектов, обладающих метками типа (type tags), то есть объектов, явно включающих информацию о том, как их
надо обрабатывать. Кроме того, мы обсудим программирование, управляемое данными
(data-directed programming) — мощную и удобную стратегию реализации, предназначенную для аддитивной сборки систем с обобщенными операциями.
Мы начнем с простого примера комплексных чисел. Мы увидим, как метки типа
и стиль, управляемый данными, позволяют нам создать отдельные декартово и полярное представления комплексных чисел, и при этом поддерживать понятие абстрактного
объекта «комплексное число» . Мы добьемся этого, определив арифметические процедуры для комплексных чисел (add-complex, sub-complex, mul-complex и divcomplex) в терминах обобщенных селекторов, которые получают части комплексного
числа независимо от того, как оно представлено. Получающаяся система работы с комплексными числами, как показано на рис. 2.19, содержит два типа барьеров абстракции.
«Горизонтальные» барьеры играют ту же роль, что и на рис. 2.1. Они отделяют «высокоуровневые» операции от «низкоуровневых» представлений. В дополнение к этому,
существует еще «вертикальный» барьер, который дает нам возможность отдельно разрабатывать и добавлять альтернативные представления.
В разделе 2.5 мы покажем, как с помощью меток типа и стиля программирования,
управляемого данными, создать арифметический пакет общего назначения. Такой пакет
дает пользователю процедуры (add, mul и т.д.), с помощью которых можно манипулировать всеми типами «чисел», и если нужно, его можно легко расширить, когда потре-

Глава 2. Построение абстракций с помощью данных

172
Мнимые

z = x + iy = re

A

iA

Действительные

Рис. 2.20. Комплексные числа как точки на плоскости

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

2.4.1. Представления комплексных чисел
В качестве простого, хотя и нереалистичного, примера программы, использующей
обобщенные операции, мы разработаем систему, которая производит арифметические
операции над комплексными числами. Начнем мы с обсуждения двух возможных представлений комплексного числа в виде упорядоченной пары: декартова форма (действительная и мнимая части) и полярная форма (модуль и аргумент)43. В разделе 2.4.2 будет
показано, как оба представления можно заставить сосуществовать в рамках одной программы при помощи меток типа и обобщенных операций.
Подобно рациональным числам, комплексные числа естественно представлять в виде
упорядоченных пар. Множество комплексных чисел можно представлять себе как двумерное пространство с двумя перпендикулярными осями: «действительной» и «мнимой»
(см. рис. 2.20). С этой точки зрения комплексное число z = x + iy (где i2 = −1) можно представить как точку на плоскости, действительная координата которой равна x,
а мнимая y. В этом представлении сложение комплексных чисел сводится к сложению
координат:
Действительная-часть(z1 + z2 ) =
= Действительная-часть(z1) + Действительная-часть(z2)
Мнимая-часть(z1 + z2 ) = Мнимая-часть(z1 ) + Мнимая-часть(z2)
43 В реальных вычислительных системах, как правило, декартова форма предпочтительнее полярной из-за
ошибок округления при преобразованиях между этими двумя формами. Именно поэтому пример с комплексными числами нереалистичен. Тем не менее, он служит ясной иллюстрацией строения системы, использующей
обобщенные операции, и хорошим введением в более содержательные системы, которые мы строим далее по
ходу этой главы.

2.4. Множественные представления для абстрактных данных

173

При умножении комплексных чисел естественней думать об их представлении в полярной форме, в виде модуля и аргумента (r и A на рис. 2.20). Произведение двух
комплексных чисел есть вектор, получаемый путем растягивания одного комплексного
числа на модуль другого и поворота на его же аргумент:
Модуль(z1 · z2 ) = Модуль(z1 ) · Модуль(z2 )
Аргумент(z1 · z2 ) = Аргумент(z1 ) + Аргумент(z2 )
Таким образом, есть два различных представления для комплексных чисел, и каждое из них удобнее для какого-то набора операций. Однако с точки зрения человека,
который пишет программу с использованием комплексных чисел, принцип абстракции
данных утверждает, что все операции, работающие с комплексными числами, должны
работать независимо от того, какую интерпретацию использует компьютер. Например,
часто бывает нужно получить модуль комплексного числа, представленного в декартовых
координатах. Подобным образом, часто полезно уметь определять действительную часть
комплексного числа, представленного в полярных координатах.
При разработке такой системы мы можем следовать той самой стратегии абстракции
данных, которую мы использовали в пакете работы с рациональными числами в разделе 2.1.1. Предположим, что операции над комплексными числами реализованы в терминах четырех селекторов: real-part, imag-part, magnitude и angle. Предположим
еще, что у нас есть две процедуры для построения комплексных чисел: make-fromreal-imag возвращает комплексное число с указанными действительной и мнимой частями, а make-from-mag-ang возвращает комплексное число с указанными модулем и
аргументом. Эти процедуры обладают такими свойствами, что для любого комплексного
числа z
(make-from-real-imag (real-part z) (imag-part z))

и
(make-from-mag-ang (magnitude z) (angle z))

порождают комплексные числа, равные z.
Используя такие конструкторы и селекторы, мы можем реализовать арифметику комплексных чисел через «абстрактные данные», определяемые этими конструкторами и
селекторами, в точности как мы это делали для рациональных чисел в разделе 2.1.1.
Как показывают вышеуказанные формулы, можно складывать и вычитать комплексные
числа в терминах действительной и мнимой части, а умножать и делить в терминах
модуля и аргумента:
(define (add-complex z1 z2)
(make-from-real-imag (+ (real-part z1) (real-part z2))
(+ (imag-part z1) (imag-part z2))))
(define (sub-complex z1 z2)
(make-from-real-imag (- (real-part z1) (real-part z2))
(- (imag-part z1) (imag-part z2))))

174

Глава 2. Построение абстракций с помощью данных

(define (mul-complex z1 z2)
(make-from-mag-ang (* (magnitude z1) (magnitude z2))
(+ (angle z1) (angle z2))))
(define (div-complex z1 z2)
(make-from-mag-ang (/ (magnitude z1) (magnitude z2))
(- (angle z1) (angle z2))))

Для того, чтобы придать пакету работы с комплексными числами окончательный
вид, нам осталось выбрать представление и реализовать конструкторы и селекторы в
терминах элементарных чисел и элементарной списковой структуры. Есть два очевидных способа это сделать: можно представлять комплексное число как пару в «декартовой
форме» (действительная часть, мнимая часть) либо в «полярной форме» (модуль, аргумент). Какой вариант мы выберем?
Чтобы говорить о конкретных вариантах, предположим, что двое программистов, Бен
Битобор и Лиза П. Хакер, независимо друг от друга разрабатывают представления для
системы, работающей с комплексными числами. Бен решает представлять комплексные
числа в декартовой форме. При таком решении доступ к действительной и мнимой частям комплексного числа, а также построение его из действительной и мнимой частей
реализуются прямолинейно. Чтобы найти модуль и аргумент, а также чтобы построить
комплексное число с заданными модулем и аргументом, он использует тригонометрические соотношения
x = r cos A
y = r sin A;
p
r = x2 + y 2 A = arctg(y, x)
которые связывают действительную и мнимую части (x, y) с модулем и аргументом
(r, A)44 . Таким образом, реализация Бена определяется следующими селекторами и конструкторами:
(define (real-part z) (car z))
(define (imag-part z) (cdr z))
(define (magnitude z)
(sqrt (+ (square (real-part z)) (square (imag-part z)))))
(define (angle z)
(atan (imag-part z) (real-part z)))
(define (make-from-real-imag x y) (cons x y))
(define (make-from-mag-ang r a)
(cons (* r (cos a)) (* r (sin a))))

Напротив, Лиза решает представить комплексные числа в полярной форме. Для нее
доступ к модулю и аргументу тривиален, но для получения действительной и мнимой
44 Функция взятия арктангенса, которая здесь используется, вычисляется процедурой Scheme atan. Она
берет два аргумента y и x и возвращает угол, тангенс которого равен y/x. Знаки аргументов определяют, в
каком квадранте находится угол.

2.4. Множественные представления для абстрактных данных

175

части ей приходится использовать тригонометрические тождества. Вот представление
Лизы:
(define (real-part z)
(* (magnitude z) (cos (angle z))))
(define (imag-part z)
(* (magnitude z) (sin (angle z))))
(define (magnitude z) (car z))
(define (angle z) (cdr z))
(define (make-from-real-imag x y)
(cons (sqrt (+ (square x) (square y)))
(atan y x)))
(define (make-from-mag-ang r a) (cons r a))

Дисциплина абстракции данных обеспечивает то, что одни и те же реализации процедур add-complex, sub-complex, mul-complex и div-complex будут работать
как с Беновым представлением, так и с Лизиным.

2.4.2. Помеченные данные
Можно рассматривать абстракцию данных как применение принципа «наименьших
обязательств». Реализуя систему обработки комплексных чисел в разделе 2.4.1, мы можем использовать либо декартово представление от Бена, либо полярное от Лизы. Барьер
абстракции, который образуют селекторы и конструкторы, позволяет нам до последнего момента отложить выбор конкретного представления для наших объектов данных, и
таким образом сохранить максимальную гибкость в проекте нашей системы.
Принцип наименьших обязательств можно довести до еще бо́льших крайностей. Если
нам понадобится, мы можем сохранить неопределенность представления даже после того,
как мы спроектировали селекторы и конструкторы, и использовать и представление Бена,
и представление Лизы. Однако если оба представления участвуют в одной и той же
системе, нам потребуется какой-нибудь способ отличить данные в полярной форме от
данных в декартовой форме. Иначе, если нас попросят, например, вычислить magnitude
от пары (3, 4), мы не будем знать, надо ли ответить 5 (интерпретируя число в декартовой
форме) или 3 (интерпретируя его в полярной форме). Естественный способ добиться
необходимого различия состоит в том, чтобы использовать метку типа (type tag) —
символ rectangular или polar — как часть каждого комплексного числа. Тогда,
когда нам понадобится что-то делать с комплексным числом, мы можем при помощи
этой метки решить, который селектор требуется применить.
Чтобы работать с помеченными данными, мы предположим, что у нас есть процедуры type-tag и contents, которые извлекают из элемента данных метку и собственно содержимое (полярные либо декартовы координаты, если речь идет о комплексном
числе). Кроме того, мы постулируем процедуру attach-tag, которая берет метку и

176

Глава 2. Построение абстракций с помощью данных

содержимое, и выдает помеченный объект данных. Простейший способ реализовать эти
процедуры — использовать обыкновенную списковую структуру:
(define (attach-tag type-tag contents)
(cons type-tag contents))
(define (type-tag datum)
(if (pair? datum)
(car datum)
(error "Некорректные помеченные данные -- TYPE-TAG" datum)))
(define (contents datum)
(if (pair? datum)
(cdr datum)
(error "Некорректные помеченные данные -- CONTENTS" datum)))

При помощи этих процедур мы можем определить предикаты rectangular? и
polar?, которые распознают, соответственно, декартово и полярное представление:
(define (rectangular? z)
(eq? (type-tag z) ’rectangular))
(define (polar? z)
(eq? (type-tag z) ’polar))

Теперь, когда у нас имеются метки типов, Бен и Лиза могут переделать свой код
так, чтобы позволить своим разнородным представлениям сосуществовать в одной и
той же системе. Каждый раз, когда Бен создает комплексное число, он помечает его
как декартово. Каждый раз, когда Лиза создает комплексное число, она помечает его
как полярное. В дополнение к этому, Бен и Лиза должны сделать так, чтобы не было
конфликта имен между названиями их процедур. Один из способов добиться этого —
Бену добавить слово rectangular к названиям всех своих процедур представления
данных, а Лизе добавить polar к своим. Вот переработанное декартово представление
Бена из раздела 2.4.1:
(define (real-part-rectangular z) (car z))
(define (imag-part-rectangular z) (cdr z))
(define (magnitude-rectangular z)
(sqrt (+ (square (real-part-rectangular z))
(square (imag-part-rectangular z)))))
(define (angle-rectangular z)
(atan (imag-part-rectangular z)
(real-part-rectangular z)))
(define (make-from-real-imag-rectangular x y)
(attach-tag ’rectangular (cons x y)))

2.4. Множественные представления для абстрактных данных

177

(define (make-from-mag-ang-rectangular r a)
(attach-tag ’rectangular
(cons (* r (cos a)) (* r (sin a)))))

а вот переработанное полярное представление Лизы:
(define (real-part-polar z)
(* (magnitude-polar z) (cos (angle-polar z))))
(define (imag-part-polar z)
(* (magnitude-polar z) (sin (angle-polar z))))
(define (magnitude-polar z) (car z))
(define (angle-polar z) (cdr z))
(define (make-from-real-imag-polar x y)
(attach-tag ’polar
(cons (sqrt (+ (square x) (square y)))
(atan y x))))
(define (make-from-mag-ang-polar r a)
(attach-tag ’polar (cons r a)))

Каждый обобщенный селектор реализуется как процедура, которая проверяет метку
своего аргумента и вызывает подходящую процедуру для обработки данных нужного
типа. Например, для того, чтобы получить действительную часть комплексного числа,
real-part смотрит на метку и решает, звать ли Бенову real-part-rectangular
или Лизину real-part-polar. В каждом из этих случаев мы пользуемся процедурой
contents, чтобы извлечь голый, непомеченный элемент данных и передать его либо в
декартову, либо в полярную процедуру:
(define (real-part z)
(cond ((rectangular? z)
(real-part-rectangular (contents z)))
((polar? z)
(real-part-polar (contents z)))
(else (error "Неизвестный тип -- REAL-PART" z))))
(define (imag-part z)
(cond ((rectangular? z)
(imag-part-rectangular (contents z)))
((polar? z)
(imag-part-polar (contents z)))
(else (error "Неизвестный тип -- IMAG-PART" z))))
(define (magnitude z)
(cond ((rectangular? z)
(magnitude-rectangular (contents z)))
((polar? z)

178

Глава 2. Построение абстракций с помощью данных
Программы, использующие комплексные числа
add-complex sub-complex mul-complex div-complex
Пакет комплексной арифметики
real-part imag-part magnitude angle
Декартово представление

Полярное представление

Списковая структура и элементарная машинная арифметика
Рис. 2.21. Структура обобщенной системы комплексной арифметики.

(magnitude-polar (contents z)))
(else (error "Неизвестный тип -- MAGNITUDE" z))))
(define (angle z)
(cond ((rectangular? z)
(angle-rectangular (contents z)))
((polar? z)
(angle-polar (contents z)))
(else (error "Неизвестный тип -- ANGLE" z))))

Для реализации арифметических операций с комплексными числами мы по-прежнему
можем использовать старые процедуры add-complex, sub-complex, mul-complex и
div-complex из раздела 2.4.1, поскольку вызываемые ими селекторы обобщенные и,
таким образом, могут работать с любым из двух представлений. Например, процедура
add-complex по-прежнему выглядит как
(define (add-complex z1 z2)
(make-from-real-imag (+ (real-part z1) (real-part z2))
(+ (imag-part z1) (imag-part z2))))

Наконец, нам надо решить, порождать ли комплексные числа в Беновом или Лизином
представлении. Одно из разумных решений состоит в том, чтобы порождать декартовы
числа, когда нам дают действительную и мнимую часть, и порождать полярные числа,
когда нам дают модуль и аргумент:
(define (make-from-real-imag x y)
(make-from-real-imag-rectangular x y))
(define (make-from-mag-ang r a)
(make-from-mag-ang-polar r a))

Структура получившейся системы комплексной арифметики показана на рисунке 2.21.
Система разбита на три относительно независимых части: операции арифметики комплексных чисел, полярная реализация Лизы и декартова реализация Бена. Полярная и

2.4. Множественные представления для абстрактных данных

179

декартова реализации могли быть написаны Беном и Лизой по отдельности, и любую
из них может использовать в качестве внутреннего представления третий программист,
чтобы реализовать процедуры арифметики комплексных чисел в терминах абстрактного
интерфейса конструкторов и селекторов.
Поскольку каждый объект данных помечен своим типом, селекторы работают с
данными обобщенным образом. Это означает, что каждый селектор по определению обладает поведением, которое зависит от того, к какому типу данных он применяется.
Следует обратить внимание на общий механизм доступа к отдельным представлениям: внутри любой данной реализации представления (скажем, внутри полярного пакета
Лизы) комплексное число представляется нетипизированной парой (модуль, аргумент).
Когда обобщенный селектор обращается к данным полярного типа, он отрывает метку и
передает содержимое Лизиному коду. И наоборот, когда Лиза строит число для общего
пользования, она помечает его тип, чтобы процедуры более высокого уровня могли его
должным образом распознать. Такая дисциплина снятия и добавления меток при передаче объектов данных с уровня на уровень может быть ценной стратегией организации
данных и программ, как мы увидим в разделе 2.5.

2.4.3. Программирование, управляемое данными, и аддитивность
Общая стратегия проверки типа данных и вызова соответствующей процедуры называется диспетчеризацией по типу (dispatching on type). Это хороший способ добиться
модульности при проектировании системы. С другой стороны, такая реализация диспетчеризации, как в разделе 2.4.2, имеет два существенных недостатка. Один заключается
в том, что обобщенные процедуры интерфейса (real-part, imag-part, magnitude
и angle) обязаны знать про все имеющиеся способы представления. Предположим, к
примеру, что нам хочется ввести в нашу систему комплексных чисел еще одно представление. Нам нужно будет сопоставить этому представлению тип, а затем добавить в
каждую из обобщенных процедур интерфейса по варианту для проверки на этот новый
тип и вызова селектора, соответствующего его представлению.
Второй недостаток этого метода диспетчеризации состоит в том, что, хотя отдельные
представления могут проектироваться раздельно, нам нужно гарантировать, что никакие
две процедуры во всей системе не называются одинаково. Вот почему Бену и Лизе
пришлось изменить имена своих первоначальных процедур из раздела 2.4.1.
Оба эти недостатка являются следствием того, что наш метод реализации обобщенных интерфейсов неаддитивен. Программист, реализующий обобщенные процедурыселекторы, должен их переделывать каждый раз, как добавляется новое представление, а авторы, создающие отдельные представления, должны изменять свой код, чтобы
избежать конфликтов имен. В каждом из этих случаев изменения, которые требуется
внести в код, тривиальны, но их все равно нужно делать, и отсюда проистекают неудобства и ошибки. Для системы работы с комплексными числами в ее нынешнем виде это
проблема небольшая, но попробуйте представить, что есть не два, а сотни различных
представлений комплексных чисел. И что есть много обобщенных селекторов, которые
надо поддерживать в интерфейсе абстрактных данных. Представьте даже, что ни один
программист не знает всех интерфейсных процедур всех реализаций. Проблема эта реальна, и с ней приходится разбираться в программах вроде систем управления базами
данных большого калибра.

Глава 2. Построение абстракций с помощью данных

180

Операции
real-part
imag-part
magnitude
angle

Типы
Polar
real-part-polar
imag-part-polar
magnitude-polar
angle-polar

Rectangular
real-part-rectangular
imag-part-rectangular
magnitude-rectangular
angle-rectangular

Рис. 2.22. Таблица операций в системе комплексных чисел.

Нам нужен способ еще более модуляризовать устройство системы. Это позволяет метод программирования, который называется программирование, управляемое данными
(data-directed programming). Чтобы понять, как работает этот метод, начнем с наблюдения: каждый раз, когда нам приходится работать с набором обобщенных операций,
общих для множества различных типов, мы, в сущности, работаем с двумерной таблицей, где по одной оси расположены возможные операции, а по другой всевозможные типы. Клеткам таблицы соответствуют процедуры, которые реализуют каждую операцию
для каждого типа ее аргумента. В системе комплексной арифметики из предыдущего
раздела соответствие между именем операции, типом данных и собственно процедурой
было размазано по условным предложениям в обобщенных процедурах интерфейса. Но
ту же самую информацию можно было бы организовать в виде таблицы, как показано
на рис. 2.22.
Программирование, управляемое данными, — метод проектирования программ, позволяющий им напрямую работать с такого рода таблицей. Механизм, который связывает
код комплексных арифметических операций с двумя пакетами представлений, мы ранее
реализовали в виде набора процедур, которые явно осуществляют диспетчеризацию по
типу. Здесь мы реализуем этот интерфейс через одну процедуру, которая ищет сочетание
имени операции и типа аргумента в таблице, чтобы определить, какую процедуру требуется применить, а затем применяет ее к содержимому аргумента. Если мы так сделаем,
то, чтобы добавить к системе пакет с новым представлением, нам не потребуется изменять существующие процедуры; понадобится только добавить новые клетки в таблицу.
Чтобы реализовать этот план, предположим, что у нас есть две процедуры put и
get, для манипуляции с таблицей операций и типов:
• (put hопi hтипi hэлементi) вносит hэлементi в таблицу, в клетку, индексом
которой служат операция hопi и тип hтипi.

• (get hопi hтипi) ищет в таблице ячейку с индексом hопi,hтипi и возвращает ее
содержимое. Если ячейки нет, get возвращает ложь.

Пока что мы предположим, что get и put входят в наш язык. В главе 3 (раздел 3.3.3)
мы увидим, как реализовать эти и другие операции для работы с таблицами.
Программирование, управляемое данными, в системе с комплексными числами можно
использовать так: Бен, который разрабатывает декартово представление, пишет код в
точности как он это делал сначала. Он определяет набор процедур, или пакет (package),
и привязывает эти процедуры к остальной системе, добавляя в таблицу ячейки, которые
сообщают системе, как работать с декартовыми числами. Это происходит при вызове

2.4. Множественные представления для абстрактных данных

181

следующей процедуры:
(define (install-rectangular-package)
;; внутренние процедуры
(define (real-part z) (car z))
(define (imag-part z) (cdr z))
(define (make-from-real-imag x y) (cons x y))
(define (magnitude z)
(sqrt (+ (square (real-part z))
(square (imag-part z)))))
(define (angle z)
(atan (imag-part z) (real-part z)))
(define (make-from-mag-ang r a)
(cons (* r (cos a)) (* r (sin a))))
;; интерфейс к остальной системе
(define (tag x) (attach-tag ’rectangular x))
(put ’real-part ’(rectangular) real-part)
(put ’imag-part ’(rectangular) imag-part)
(put ’magnitude ’(rectangular) magnitude)
(put ’angle ’(rectangular) angle)
(put ’make-from-real-imag ’rectangular
(lambda (x y) (tag (make-from-real-imag x y))))
(put ’make-from-mag-ang ’rectangular
(lambda (r a) (tag (make-from-mag-ang r a))))
’done)

Обратите внимание, что внутренние процедуры — те самые, которые Бен писал, когда он в разделе 2.4.1 работал сам по себе. Никаких изменений, чтобы связать их с
остальной системой, не требуется. Более того, поскольку определения процедур содержатся внутри процедуры установки, Бену незачем беспокоиться о конфликтах имен с
другими процедурами вне декартова пакета. Чтобы связать их с остальной системой,
Бен устанавливает свою процедуру real-part под именем операции real-part и
типом (rectangular), и то же самое он проделывает с другими селекторами45 . Его
интерфейс также определяет конструкторы, которые может использовать внешняя система46 . Они совпадают с конструкторами, которые Бен определяет для себя, но вдобавок
прикрепляют метку.
Лизин полярный пакет устроен аналогично:
(define (install-polar-package)
;; внутренние процедуры
(define (magnitude z) (car z))
(define (angle z) (cdr z))
(define (make-from-mag-ang r a) (cons r a))
(define (real-part z)
45 Мы используем список (rectangular), а не символ rectangular, чтобы предусмотреть возможность
операций с несколькими аргументами, не все из которых одинакового типа.
46 Тип, под которым устанавливаются конструкторы, необязательно делать списком, поскольку конструктор
всегда вызывается для того, чтобы породить один объект определенного типа.

Глава 2. Построение абстракций с помощью данных

182

(* (magnitude z) (cos (angle z))))
(define (imag-part z)
(* (magnitude z) (sin (angle z))))
(define (make-from-real-imag x y)
(cons (sqrt (+ (square x) (square y)))
(atan y x)))
;; интерфейс к остальной системе
(define (tag x) (attach-tag ’polar x))
(put ’real-part ’(polar) real-part)
(put ’imag-part ’(polar) imag-part)
(put ’magnitude ’(polar) magnitude)
(put ’angle ’(polar) angle)
(put ’make-from-real-imag ’polar
(lambda (x y) (tag (make-from-real-imag x y))))
(put ’make-from-mag-ang ’polar
(lambda (r a) (tag (make-from-mag-ang r a))))
’done)

Несмотря на то, что Бен и Лиза используют свои исходные процедуры с совпадающими именами (например, real-part), эти определения теперь внутренние для различных
процедур (см. раздел 1.1.8), так что никакого конфликта имен не происходит.
Селекторы комплексной арифметики обращаются к таблице посредством общей процедуры-«операции» apply-generic, которая применяет обобщенную операцию к набору аргументов. Apply-generic ищет в таблице ячейку по имени операции и типам
аргументов и применяет найденную процедуру, если она существует47 :
(define (apply-generic op . args)
(let ((type-tags (map type-tag args)))
(let ((proc (get op type-tags)))
(if proc
(apply proc (map contents args))
(error
"Нет метода для этих типов -- APPLY-GENERIC"
(list op type-tags))))))

При помощи apply-generic можно определить обобщенные селекторы так:
(define
(define
(define
(define

(real-part z) (apply-generic ’real-part z))
(imag-part z) (apply-generic ’imag-part z))
(magnitude z) (apply-generic ’magnitude z))
(angle z) (apply-generic ’angle z))

47 Apply-generic пользуется точечной записью, описанной в упражнении 2.20, поскольку различные обобщенные операции могут принимать различное число аргументов. В apply-generic значением op является
первый аргумент вызова apply-generic, а значением args список остальных аргументов.
Кроме того, apply-generic пользуется элементарной процедурой apply, которая принимает два аргумента: процедуру и список. Apply вызывает процедуру, используя элементы списка как аргументы. Например,

(apply + (list 1 2 3 4))
возвращает 10.

2.4. Множественные представления для абстрактных данных

183

Заметим, что они не изменяются, если в систему добавляется новое представление.
Кроме того, мы можем из той же таблицы получать конструкторы, которые будут использоваться программами, внешними по отношению к пакетам, для изготовления комплексных чисел из действительной и мнимой части либо из модуля и аргумента. Как и
в разделе 2.4.2, мы порождаем декартово представление, если нам дают действительную
и мнимую часть, и полярное, если дают модуль и аргумент:
(define (make-from-real-imag x y)
((get ’make-from-real-imag ’rectangular) x y))
(define (make-from-mag-ang r a)
((get ’make-from-mag-ang ’polar) r a))

Упражнение 2.73.
В разделе 2.3.2 описывается программа, которая осуществляет символьное дифференцирование:
(define (deriv exp var)
(cond ((number? exp) 0)
((variable? exp) (if (same-variable? exp var) 1 0))
((sum? exp)
(make-sum (deriv (addend exp) var)
(deriv (augend exp) var)))
((product? exp)
(make-sum
(make-product (multiplier exp)
(deriv (multiplicand exp) var))
(make-product (deriv (multiplier exp) var)
(multiplicand exp))))
hЗдесь можно добавить еще правилаi
(else (error "неизвестный тип выражения -- DERIV" exp))))
Можно считать, что эта программа осуществляет диспетчеризацию по типу выражения, которое
требуется продифференцировать. В этом случае «меткой типа» элемента данных является символ
алгебраической операции (например, +), а операция, которую нужно применить – deriv. Эту программу можно преобразовать в управляемый данными стиль, если переписать основную процедуру
взятия производной в виде
(define (deriv exp var)
(cond ((number? exp) 0)
((variable? exp) (if (same-variable? exp var) 1 0))
(else ((get ’deriv (operator exp)) (operands exp)
var))))
(define (operator exp) (car exp))
(define (operands exp) (cdr exp))
а. Объясните, что происходит в приведенном фрагменте кода. Почему нельзя включить в операцию выбора, управляемого данными, предикаты number? и variable??
б. Напишите процедуры для вычисления производных от суммы и произведения, а также дополнительный код, чтобы добавить их к таблице, которой пользуется приведенный фрагмент.

184

Глава 2. Построение абстракций с помощью данных

в. Выберите еще какое-нибудь правило дифференцирования, например для возведения в степень
(упражнение 2.56), и установите его в систему.
г. В этой простой алгебраической системе тип выражения — это алгебраическая операция верхнего уровня. Допустим, однако, что мы индексируем процедуры противоположным образом, так
что строка диспетчеризации в deriv выглядит как
((get (operator exp) ’deriv) (operands exp) var)
Какие изменения потребуются в системе дифференцирования?
Упражнение 2.74.
Insatiable Enterprises, Inc. — децентрализованная компания-конгломерат, которая состоит из большого количества независимых подразделений, раскиданных по всему миру. Недавно вычислительные мощности компании были связаны умной вычислительной сетью, создающей для пользователя
иллюзию, что он работает с единым компьютером. Президент компании, когда она в первый раз
пытается воспользоваться способностью системы осуществлять доступ к файлам подразделений, с
изумлением и ужасом обнаруживает, что, несмотря на то, что все эти файлы реализованы в виде
структур данных на Scheme, конкретная структура данных отличается от подразделения к подразделению. Спешно созывается совещание менеджеров подразделений, чтобы найти стратегию,
которая позволила бы собрать файлы в единую систему для удовлетворения нужд главного офиса,
и одновременно сохранить существующую автономию подразделений.
Покажите, как такую стратегию можно реализовать при помощи программирования, управляемого данными. К примеру, предположим, что сведения о персонале каждого подразделения
устроены в виде единого файла, который содержит набор записей, проиндексированных по имени
служащего. Структура набора данных от подразделения к подразделению различается. Более того,
каждая запись сама по себе — набор сведений (в разных подразделениях устроенный по-разному),
в котором информация индексируется метками вроде address (адрес) или salary (зарплата). В
частности:
а. Для главного офиса реализуйте процедуру get-record, которая получает запись, относящуюся к указанному служащему, из указанного файла персонала. Процедура должна быть применима
к файлу любого подразделения. Объясните, как должны быть структурированы файлы отдельных
подразделений. В частности, какую информацию о типах нужно хранить?
б. Для главного офиса реализуйте процедуру get-salary, которая возвращает зарплату указанного служащего из файла любого подразделения. Как должна быть устроена запись, чтобы
могла работать эта процедура?
в. Для главного офиса напишите процедуру find-employee-record. Она должна искать в
файлах всех подразделений запись указанного служащего и возвращать эту запись. Предположим, что в качестве аргументов эта процедура принимает имя служащего и список файлов всех
подразделений.
г. Какие изменения требуется внести в систему, чтобы внести в центральную систему информацию о новых служащих, когда Insatiable поглощает новую компанию?

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

2.4. Множественные представления для абстрактных данных

185

внутри каждой операции, и каждая операция должна сама заботиться о своей диспетчеризации. Это, в сущности, разбивает таблицу операций и типов на строки, и каждая
обобщенная операция представляет собой строку таблицы.
Альтернативой такой стратегии реализации будет разбить таблицу по столбцам и
вместо «умных операций», которые диспетчируют по типам данных, работать с «умными объектами данных», которые диспетчируют по именам операций. Мы можем этого
добиться, если устроим все так, что объект данных, например комплексное число в декартовом представлении, будет представляться в виде процедуры, которая в качестве
входа воспринимает имя операции и осуществляет соответствующее ей действие. При
такой организации можно написать make-from-real-imag в виде
(define (make-from-real-imag x y)
(define (dispatch op)
(cond ((eq? op ’real-part) x)
((eq? op ’imag-part) y)
((eq? op ’magnitude)
(sqrt (+ (square x) (square y))))
((eq? op ’angle) (atan y x))
(else
(error "Неизвестная оп. -- MAKE-FROM-REAL-IMAG" op))))
dispatch)

Соответствующая процедура apply-generic, которая применяет обобщенную операцию к аргументу, просто скармливает имя операции объекту данных и заставляет его
делать всю работу48 :
(define (apply-generic op arg) (arg op))

Обратите внимание, что значение, возвращаемое из make-from-real-imag, является
процедурой — это внутренняя процедура dispatch. Она вызывается, когда applygeneric требует выполнить обобщенную операцию.
Такой стиль программирования называется передача сообщений (message passing).
Имя происходит из представления, что объект данных — это сущность, которая получает имя затребованной операции как «сообщение». Мы уже встречались с примером
передачи сообщений в разделе 2.1.3, где мы видели, как cons, car и cdr можно определить безо всяких объектов данных, с одними только процедурами. Теперь мы видим,
что передача сообщений не математический трюк, а полезный метод организации систем с обобщенными операциями. В оставшейся части этой главы мы будем продолжать
пользоваться программированием, управляемым данными, а не передачей сообщений, и
рассмотрим обобщенные арифметические операции. Мы вернемся к передаче сообщений
в главе 3, и увидим, что она может служить мощным инструментом для структурирования моделирующих программ.
Упражнение 2.75.
Реализуйте в стиле передачи сообщений конструктор make-from-mag-ang. Он должен быть
аналогичен приведенной выше процедуре make-from-real-imag.
48 У

такой организации есть ограничение: она допускает обобщенные процедуры только от одного аргумента.

Глава 2. Построение абстракций с помощью данных

186

Программы, использующие числа
add sub mul div
Пакет обобщенной арифметики
add-rat sub-rat
mul-rat div-rat
Рациональная
арифметика

add-complex sub-complex
+ - * /
mul-complex div-complex
Комплексная арифметика
Декартова

Обыкновенная
арифметика

Полярная

Списковая структура и элементарная арифметика машины
Рис. 2.23. Обобщенная арифметическая истема.

Упражнение 2.76.
Когда большая система с обобщенными операциями развивается, могут потребоваться новые типы
объектов данных или новые операции. Для каждой из трех стратегий — обобщенные операции с
явной диспетчеризацией, стиль, управляемый данными, и передача сообщений, – опишите, какие
изменения нужно произвести в системе, чтобы добавить новый тип или новую операцию. Какая
организация лучше подходит для системы, в которую часто добавляются новые типы? Какая для
системы, где часто появляются новые операции?

2.5. Системы с обобщенными операциями
В предыдущем разделе мы увидели, как проектировать системы, где объекты данных могут быть представлены более чем одним способом. Основная идея состоит в том,
чтобы связать код, который определяет операции над данными, и многочисленные реализации данных, при помощи обобщенных процедур интерфейса. Теперь мы увидим, что
ту же самую идею можно использовать не только для того, чтобы определять обобщенные операции для нескольких реализаций одного типа, но и для того, чтобы определять
операции, обобщенные относительно нескольких различных типов аргументов. Мы уже
встречались с несколькими различными пакетами арифметических операций: элементарная арифметика (+, -, *, /), встроенная в наш язык, арифметика рациональных чисел
(add-rat, sub-rat, mul-rat, div-rat) из раздела 2.1.1 и арифметика комплексных
чисел, которую мы реализовали в разделе 2.4.3. Теперь мы, используя методы программирования, управляемого данными, создадим пакет арифметических операций, который
включает все уже построенные нами арифметические пакеты.
На рисунке 2.23 показана структура системы, которую мы собираемся построить.
Обратите внимание на барьеры абстракции. С точки зрения человека, работающего с
«числами», есть только одна процедура add, которая работает, какие бы числа ей ни
дали. Add является частью обобщенного интерфейса, который позволяет программам,

2.5. Системы с обобщенными операциями

187

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

2.5.1. Обобщенные арифметические операции
Задача проектирования обобщенных арифметических операций аналогична задаче
проектирования обобщенных операций с комплексными числами. К примеру, нам бы
хотелось иметь обобщенную процедуру сложения add, которая действовала бы как обычное элементарное сложение + по отношению к обычным числам, как add-rat по отношению к рациональным числам и как add-complex по отношению к комплексным.
Реализовать add и прочие обобщенные арифметические операции мы можем, следуя той
же стратегии, которую мы использовали в разделе 2.4.3 для обобщенных селекторов
комплексных чисел. К каждому числу мы прикрепим метку типа и заставим обобщенную процедуру передавать управление в нужный пакет в соответствии с типами своих
аргументов.
Обобщенные арифметические процедуры определяются следующим образом:
(define
(define
(define
(define

(add
(sub
(mul
(div

x
x
x
x

y)
y)
y)
y)

(apply-generic
(apply-generic
(apply-generic
(apply-generic

’add
’sub
’mul
’div

x
x
x
x

y))
y))
y))
y))

Начнем с установки пакета для работы с обычными числами, то есть элементарными
числами нашего языка. Мы пометим их символом scheme-number. Арифметические
операции этого пакета — это элементарные арифметические процедуры (так что нет
никакой нужды определять дополнительные процедуры для обработки непомеченных
чисел). Поскольку каждая из них принимает по два аргумента, в таблицу они заносятся
с ключом-списком (scheme-number scheme-number):
(define (install-scheme-number-package)
(define (tag x)
(attach-tag ’scheme-number x))
(put ’add ’(scheme-number scheme-number)
(lambda (x y) (tag (+ x y))))
(put ’sub ’(scheme-number scheme-number)
(lambda (x y) (tag (- x y))))
(put ’mul ’(scheme-number scheme-number)
(lambda (x y) (tag (* x y))))
(put ’div ’(scheme-number scheme-number)
(lambda (x y) (tag (/ x y))))
(put ’make ’scheme-number
(lambda (x) (tag x)))
’done)

188

Глава 2. Построение абстракций с помощью данных

Пользователи пакета Схемных чисел будут создавать (помеченные) элементарные
числа с помощью процедуры
(define (make-scheme-number n)
((get ’make ’scheme-number) n))

Теперь, когда каркас обобщенной арифметической системы построен, мы можем без
труда добавлять новые типы чисел. Вот пакет, который реализует арифметику рациональных чисел. Обратите внимание, что благодаря аддитивности мы можем без изменений использовать код рациональной арифметики из раздела 2.1.1 в виде внутренних
процедур пакета:
(define (install-rational-package)
;; внутренние процедуры
(define (numer x) (car x))
(define (denom x) (cdr x))
(define (make-rat n d)
(let ((g (gcd n d)))
(cons (/ n g) (/ d g))))
(define (add-rat x y)
(make-rat (+ (* (numer x) (denom y))
(* (numer y) (denom x)))
(* (denom x) (denom y))))
(define (sub-rat x y)
(make-rat (- (* (numer x) (denom y))
(* (numer y) (denom x)))
(* (denom x) (denom y))))
(define (mul-rat x y)
(make-rat (* (numer x) (numer y))
(* (denom x) (denom y))))
(define (div-rat x y)
(make-rat (* (numer x) (denom y))
(* (denom x) (numer y))))
;; интерфейс к остальной системе
(define (tag x) (attach-tag ’rational x))
(put ’add ’(rational rational)
(lambda (x y) (tag (add-rat x y))))
(put ’sub ’(rational rational)
(lambda (x y) (tag (sub-rat x y))))
(put ’mul ’(rational rational)
(lambda (x y) (tag (mul-rat x y))))
(put ’div ’(rational rational)
(lambda (x y) (tag (div-rat x y))))
(put ’make ’rational
(lambda (n d) (tag (make-rat n d))))
’done)
(define (make-rational n d)
((get ’make ’rational) n d))

2.5. Системы с обобщенными операциями

189

Мы можем установить подобный пакет и для комплексных чисел, используя метку
complex. При создании пакета мы извлекаем из таблицы операции make-from-realimag и make-from-mag-ang, определенные в декартовом и полярном пакетах. Аддитивность позволяет нам использовать без изменений в качестве внутренних операций
процедуры add-complex, sub-complex, mul-complex и div-complex из раздела 2.4.1.
(define (install-complex-package)
;; процедуры, импортируемые из декартова
;; и полярного пакетов
(define (make-from-real-imag x y)
((get ’make-from-real-imag ’rectangular) x y))
(define (make-from-mag-ang r a)
((get ’make-from-mag-ang ’polar) r a))
;; внутренние процедуры
(define (add-complex z1 z2)
(make-from-real-imag (+ (real-part z1) (real-part z2))
(+ (imag-part z1) (imag-part z2))))
(define (sub-complex z1 z2)
(make-from-real-imag (- (real-part z1) (real-part z2))
(- (imag-part z1) (imag-part z2))))
(define (mul-complex z1 z2)
(make-from-mag-ang (* (magnitude z1) (magnitude z2))
(+ (angle z1) (angle z2))))
(define (div-complex z1 z2)
(make-from-mag-ang (/ (magnitude z1) (magnitude z2))
(- (angle z1) (angle z2))))
;; интерфейс к остальной системе
(define (tag z) (attach-tag ’complex z))
(put ’add ’(complex complex)
(lambda (z1 z2) (tag (add-complex z1 z2))))
(put ’sub ’(complex complex)
(lambda (z1 z2) (tag (sub-complex z1 z2))))
(put ’mul ’(complex complex)
(lambda (z1 z2) (tag (mul-complex z1 z2))))
(put ’div ’(complex complex)
(lambda (z1 z2) (tag (div-complex z1 z2))))
(put ’make-from-real-imag ’complex
(lambda (x y) (tag (make-from-real-imag x y))))
(put ’make-from-mag-ang ’complex
(lambda (r a) (tag (make-from-mag-ang r a))))
’done)

Вне комплексного пакета программы могут создавать комплексные числа либо из
действительной и мнимой части, либо из модуля и аргумента. Обратите внимание, как
нижележащие процедуры, которые были изначально определены в декартовом и полярном пакете, экспортируются в комплексный пакет, а оттуда во внешний мир.
(define (make-complex-from-real-imag x y)
((get ’make-from-real-imag ’complex) x y))

Глава 2. Построение абстракций с помощью данных

190

complex

rectangular

3

4

Рис. 2.24. Представление 3 + 4i в декартовой форме.

(define (make-complex-from-mag-ang r a)
((get ’make-from-mag-ang ’complex) r a))

Здесь мы имеем двухуровневую систему меток. Типичное комплексное число, например 3 + 4i в декартовой форме, будет представлено так, как показано на рисунке 2.24.
Внешняя метка (complex) используется, чтобы отнести число к пакету комплексных
чисел. Внутри комплексного пакета вторая метка (rectangular) относит число к декартову пакету. В большой и сложной системе может быть несколько уровней, каждый
из которых связан со следующим при помощи обобщенных операций. Когда объект данных передается «вниз», внешняя метка, которая используется для отнесения к нужному
пакету, отрывается (при помощи вызова contents), и следующий уровень меток (если
таковой имеется) становится доступным для дальнейшей диспетчеризации.
В приведенных пакетах мы использовали add-rat, add-complex и другие арифметические процедуры ровно в таком виде, как они были написаны с самого начала.
Но когда эти определения оказываются внутри различных процедур установки, отпадает
необходимость давать им различные имена: мы могли бы просто назвать их в обоих
пакетах add, sub, mul и div.
Упражнение 2.77.
Хьюго Дум пытается вычислить выражение (magnitude z), где z — объект, показанный на
рис. 2.24. К своему удивлению, вместо ответа 5 он получает сообщение об ошибке от applygeneric, гласящее, что у операции magnitude нет методов для типа (complex). Он показывает
результат Лизе П. Хакер. Та заявляет: "Дело в том, что селекторы комплексных чисел для чисел с
меткой complex определены не были, а были только для чисел с меткой polar и rectangular.
Все, что требуется, чтобы заставить это работать — это добавить к пакету complex следующее:"
(put
(put
(put
(put

’real-part ’(complex) real-part)
’imag-part ’(complex) imag-part)
’magnitude ’(complex) magnitude)
’angle ’(complex) angle)

Подробно опишите, почему это работает. В качестве примера, проследите все процедуры, которые
вызываются при вычислении (magnitude z), где z — объект, показанный на рис. 2.24. В частности, сколько раз вызывается apply-generic? На какую процедуру она диспетчирует в каждом
случае?
Упражнение 2.78.
В пакете scheme-number внутренние процедуры, в сущности, ничего не делают, только вызывают
элементарные процедуры +, -, и т.д. Прямо использовать примитивы языка не было возможности,

2.5. Системы с обобщенными операциями

191

поскольку наша система меток типов требует, чтобы каждый объект данных был снабжен меткой.
Однако на самом деле все реализации Лиспа имеют систему типов, которую они используют внутри себя. Элементарные процедуры вроде symbol? или number? определяют, относится ли объект
к определенному типу. Измените определения type-tag, contents и attach-tag из раздела 2.4.2 так, чтобы наша обобщенная система использовала внутреннюю систему типов Scheme.
То есть, система должна работать так же, как раньше, но только обычные числа должны быть
представлены просто в виде чисел языка Scheme, а не в виде пары, у которой первый элемент
символ scheme-number.
Упражнение 2.79.
Определите обобщенный предикат равенства equ?, который проверяет два числа на равенство,
и вставьте его в пакет обобщенной арифметики. Операция должна работать для обычных чисел,
рациональных и комплексных.
Упражнение 2.80.
Определите обобщенный предикат =zero?, который проверяет, равен ли его аргумент нулю, и
вставьте его в пакет обобщенной арифметики. Предикат должен работать для обычных, рациональных и комплексных чисел.

2.5.2. Сочетание данных различных типов
Мы видели, как можно построить объединенную арифметическую систему, которая
охватывает обыкновенные числа, комплексные числа, рациональные числа и любые другие типы чисел, которые нам может потребоваться изобрести, но мы упустили важный
момент. Операции, которые мы до сих пор определили, рассматривают различные типы
данных как совершенно независимые. Таким образом, есть отдельные пакеты для сложения, например, двух обыкновенных чисел и двух комплексных чисел. Мы до сих пор
не учитывали того, что имеет смысл определять операции, которые пересекают границы
типов, например, сложение комплексного числа с обычным. Мы затратили немалые усилия, чтобы воздвигнуть барьеры между частями наших программ, так, чтобы их можно
было разрабатывать и понимать по отдельности. Нам бы хотелось добавить операции со
смешанными типами по возможности аккуратно, так, чтобы мы их могли поддерживать,
не нарушая всерьез границ модулей.
Один из способов управления операциями со смешанными типами состоит в том,
чтобы определить отдельную процедуру для каждого сочетания типов, для которых операция имеет смысл. Например, мы могли бы расширить пакет работы с комплексными
числами и включить туда процедуру сложения комплексных чисел с обычными, занося
ее в таблицу с меткой (complex scheme-number)49:
;; включается в пакет комплексных чисел
(define (add-complex-to-schemenum z x)
(make-from-real-imag (+ (real-part z) x)
(imag-part z)))
(put ’add ’(complex scheme-number)
(lambda (z x) (tag (add-complex-to-schemenum z x))))
49 Придется

к тому же написать почти такую же процедуру для типа (scheme-number complex).

192

Глава 2. Построение абстракций с помощью данных

Этот метод работает, но он очень громоздок. При такой системе стоимость введения нового типа не сводится к тому, чтобы построить пакет процедур для этого типа,
но включает еще построение и установку процедур, осуществляющих операции со смешанными типами. Это запросто может потребовать больше кода, чем нужно, чтобы
определить операции над самим типом. Кроме того, этот метод подрывает нашу способность сочетать отдельные пакеты аддитивно, или, по крайней мере, ограничивать
степень, в которой реализация отдельного пакета должна принимать другие пакеты в
расчет. Скажем, в вышеприведенном примере, кажется естественным, чтобы ответственность за обработку смешанных операций с обычными и комплексными числами лежала
на комплексном пакете. Однако сочетание рациональных и комплексных чисел может
осуществляться комплексным пакетом, рациональным пакетом, или каким-нибудь третьим, который пользуется операциями, извлеченными из этих двух. Формулировка ясных
правил разделения ответственности между пакетами может стать непосильной задачей
при разработке систем с многими пакетами и многими смешанными операциями.
Приведение типов
В ситуации общего вида, когда совершенно несвязанные друг с другом операции применяются к совершенно друг с другом не связанным типам, явное написание операций
со смешанными типами, как бы это ни было громоздко, — все, на что мы можем рассчитывать. К счастью, обычно мы можем воспользоваться дополнительной структурой,
которая часто в скрытом виде присутствует в нашей системе типов. Часто различные
типы данных не совсем независимы, и каким-то образом объекты одного типа можно рассматривать как объекты другого. Такой процесс называется приведением типов
(coercion). Например, если нас просят найти некоторую арифметическую комбинацию
обычного числа и комплексного, то мы можем рассматривать обычное число как такое
комплексное, у которого мнимая часть равна нулю. Это сводит нашу задачу к сочетанию двух комплексных чисел, а с этим может стандартным способом справиться пакет
комплексной арифметики.
В общем случае мы можем реализовать эту идею, проектируя процедуры приведения
типа, которые переводят объект одного типа в эквивалентный ему объект другого типа.
Вот типичная процедура приведения типов, которая преобразует данное обыкновенное
число в комплексное, у которого есть действительная часть, а мнимая равна нулю:
(define (scheme-number->complex n)
(make-complex-from-real-imag (contents n) 0))

Мы записываем процедуры приведения типа в специальную таблицу приведения типов,
проиндексированную именами двух типов:
(put-coercion ’scheme-number ’complex scheme-number->complex)

(Предполагается, что для работы с этой таблицей существуют процедуры put-coercion
и get-coercion.) Как правило, часть ячеек этой таблицы будет пуста, потому что в
общем случае невозможно привести произвольный объект произвольного типа ко всем
остальным типам. К примеру, нет способа привести произвольное комплексное число к
обыкновенному, так что в таблице не появится общая процедура complex->schemenumber.

2.5. Системы с обобщенными операциями

193

Когда таблица приведения типов построена, мы можем работать с приведением стандартным образом, приспособив для этого процедуру apply-generic из раздела 2.4.3.
Когда нас просят применить операцию, мы первым делом, как и раньше, проверяем, не
определена ли уже операция для типов аргументов. Если да, мы вызываем процедуру,
найденную в таблице операций и типов. Если нет, мы пробуем применить приведение
типов. Для простоты мы рассматриваем только тот случай, когда аргументов два50 . Мы
проверяем таблицу преобразования типов и смотрим, можно ли объект первого типа привести ко второму типу. Если да, осуществляем приведение и снова пробуем операцию.
Если объекты первого типа в общем случае ко второму не приводятся, мы пробуем приведение в обратном направлении и смотрим, нет ли способа привести второй аргумент
к типу первого. Наконец, если нет никакого известного способа привести один тип к
другому, мы сдаемся. Вот эта процедура:
(define (apply-generic op . args)
(let ((type-tags (map type-tag args)))
(let ((proc (get op type-tags)))
(if proc
(apply proc (map contents args))
(if (= (length args) 2)
(let ((type1 (car type-tags))
(type2 (cadr type-tags))
(a1 (car args))
(a2 (cadr args)))
(let ((t1->t2 (get-coercion type1 type2))
(t2->t1 (get-coercion type2 type1)))
(cond (t1->t2
(apply-generic op (t1->t2 a1) a2))
(t2->t1
(apply-generic op a1 (t2->t1 a2)))
(else
(error "Нет метода для этих типов"
(list op type-tags))))))
(error "Нет метода для этих типов"
(list op type-tags)))))))

Такая схема приведения типов имеет много преимуществ перед методом явного определения смешанных операций, как это описано выше. Хотя нам по-прежнему требуется
писать процедуры приведения для связи типов (возможно, n2 процедур для системы с n
типами), для каждой пары типов нам нужно написать только одну процедуру, а не по
процедуре на каждый набор типов и каждую обобщенную операцию51 . Здесь мы рассчитываем на то, что требуемая трансформация типов зависит только от самих типов, и не
зависит от операции, которую требуется применить.
50 Обобщение

см. в упражнении 2.82.
мы умные, мы обычно можем обойтись меньше, чем n2 процедурами приведения типа. Например,
если мы знаем, как из типа 1 получить тип 2, а из типа 2 тип 3, то можно использовать это знание для
преобразования из 1 в 3. Это может сильно уменьшить количество процедур, которые надо явно задавать при
введении нового типа в систему. Если нам не страшно ввести в свою систему требуемый уровень изощренности,
мы можем заставить ее искать по «графу» отношений между типами и автоматически порождать все процедуры
приведения типов, которые можно вывести из тех, которые явно заданы.
51 Если

194

Глава 2. Построение абстракций с помощью данных
комплексные



рациональные

целые

действительные

Рис. 2.25. Башня типов

С другой стороны, могут существовать приложения, для которых наша схема приведения недостаточно обща. Даже когда ни один из объектов, которые требуется сочетать,
не может быть приведен к типу другого, операция может оказаться применимой, если
преобразовать оба объекта к третьему типу. Чтобы справиться с такой степенью сложности и по-прежнему сохранить модульность в наших программах, обычно необходимо
строить такие системы, которые еще в большей степени используют структуру в отношениях между типами, как мы сейчас расскажем.
Иерархии типов
Описанная выше схема приведения типов опиралась на существование естественных
отношений между парами типов. Часто в отношениях типов между собой существует более «глобальная» структура. Предположим, например, что мы строим обобщенную
арифметическую систему, которая должна работать с целыми, рациональными, действительными и комплексными числами. В такой системе вполне естественно будет рассматривать целое число как частный случай рационального, которое в свою очередь является
частным случаем действительного числа, которое опять-таки частный случай комплексного числа. Здесь мы имеем так называемую иерархию типов (hierarchy of types) в
которой, например, целые числа являются подтипом (subtype) рациональных чисел (то
есть всякая операция, которую можно применить к рациональному числу, применима и
к целым). Соответственно, мы говорим, что рациональные числа являются надтипом
(supertype) целых. Та конкретная иерархия, с которой мы имеем дело здесь, имеет очень
простой вид, а именно, у каждого типа не более одного надтипа и не более одного
подтипа. Такая структура, называемая башня типов (tower), показана на рис. 2.25.
Если у нас имеется башня типов, то задача добавления нового типа в систему сильно
упрощается, поскольку требуется указать только то, каким образом новый тип включается в ближайший надтип сверху и то, каким образом он является надтипом типа,
который находится прямо под ним. Например, если мы хотим к комплексному числу добавить целое, нам не нужно специально определять процедуру приведения типа
integer->complex. Вместо этого мы определяем, как можно перевести целое число в
рациональное, рациональное в действительное, и как действительное число переводится
в комплексное. Потом мы позволяем системе преобразовать целое число в комплексное
через все эти промежуточные шаги и складываем два комплексных числа.
Можно переопределить процедуру apply-generic следующим образом: для каж-

2.5. Системы с обобщенными операциями

195

дого типа требуется указать процедуру raise, которая «поднимает» объекты этого типа
на один уровень в башне. В таком случае, когда системе требуется обработать объекты
различных типов, она может последовательно поднимать объекты более низких типов,
пока все объекты не окажутся на одном и том же уровне башни. (Упражнения 2.83 и
2.84 касаются деталей реализации такой стратегии.)
Еще одно преимущество башни состоит в том, что легко реализуется представление
о том, что всякий тип «наследует» операции своего надтипа. Например, если мы не даем
особой процедуры для нахождения действительной части целого числа, мы все равно
можем ожидать, что real-part будет для них определена в силу того, что целые числа
являются подтипом комплексных. В случае башни мы можем устроить так, чтобы это
происходило само собой, модифицировав apply-generic. Если требуемая операция не
определена непосредственно для типа данного объекта, мы поднимаем его до надтипа
и пробуем еще раз. Так мы ползем вверх по башне, преобразуя по пути свой аргумент,
пока мы либо не найдем уровень, на котором требуемую операцию можно произвести,
либо не доберемся до вершины (и в таком случае мы сдаемся).
Еще одно преимущество башни над иерархией более общего типа состоит в том, что
она дает нам простой способ «опустить» объект данных до его простейшего представления. Например, если мы складываем 2 + 3i с 4 − 3i, было бы приятно в качестве ответа
получить целое 6, а не комплексное 6 + 0i. В упражнении 2.85 обсуждается способ,
которым такую понижающую операцию можно реализовать. (Сложность в том, что нам
нужен общий способ отличить объекты, которые можно понизить, вроде 6 + 0i, от тех,
которые понизить нельзя, например 6 + 2i.)
Неадекватность иерархий
Если типы данных в нашей системе естественным образом выстраиваются в башню, это сильно упрощает задачу работы с обобщенными операциями над различными
типами, как мы только что видели. К сожалению, обычно это не так. На рисунке 2.26
показано более сложное устройство набора типов, а именно отношения между различными типами геометрических фигур. Мы видим, что в общем случае у типа может быть
более одного подтипа. Например, и треугольники, и четырехугольники являются разновидностями многоугольников. В дополнение к этому, у типа может быть более одного
надтипа. Например, равнобедренный прямоугольный треугольник можно рассматривать
и как равнобедренный, и как прямоугольный. Вопрос с множественными надтипами особенно болезнен, поскольку из-за него теряется единый способ «поднять» тип по иерархии. Нахождение «правильного» надтипа, в котором требуется применить операцию к
объекту, может потребовать долгого поиска по всей сети типов внутри процедуры вроде
apply-generic. Поскольку в общем случае у типа несколько подтипов, существует
подобная проблема и в сдвиге значения «вниз» по иерархии. Работа с большим количеством связанных типов без потери модульности при разработке больших систем – задача
очень трудная, и в этой области сейчас ведется много исследований52 .
52 Данное утверждение, которое присутствует и в первом издании этой книги, сейчас столь же верно, как
и двенадцать лет назад. Разработка удобного, достаточно общего способа выражать отношения между различными типами сущностей (то, что философы называют «онтологией»), оказывается невероятно сложным
делом. Основная разница между той путаницей, которая была десять лет назад, и той, которая есть сейчас, состоит в том, что теперь множество неадекватных онтологических теорий оказалось воплощено в массе соответственно неадекватных языков программирования. Например, львиная доля сложности объектно-

Глава 2. Построение абстракций с помощью данных

196

многоугольник

четырехугольник

трапеция
треугольник

четырехугольник
с перпендикулярными диагоналями

параллелограмм
равнобедренный
треугольник

прямоугольный
треугольник
прямоугольник

равносторонний
треугольник

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

ромб

квадрат

Рис. 2.26. Отношения между типами геометрических фигур.

2.5. Системы с обобщенными операциями

197

Упражнение 2.81.
Хьюго Дум заметил, что apply-generic может пытаться привести аргументы к типу друг друга
даже тогда, когда их типы и так совпадают. Следовательно, решает он, нам нужно вставить
в таблицу приведения процедуры, которые «приводят» аргументы каждого типа к нему самому.
Например, в дополнение к приведению scheme-number->complex, описанному выше, он бы
написал еще:
(define (scheme-number->scheme-number n) n)
(define (complex->complex z) z)
(put-coercion ’scheme-number ’scheme-number
scheme-number->scheme-number)
(put-coercion ’complex ’complex complex->complex)
а. Если установлены процедуры приведения типов, написанные Хьюго, что произойдет, когда
apply-generic будет вызвана с двумя аргументами типа scheme-number или двумя аргументами типа complex для операции, которая не находится в таблице для этих типов? Допустим,
например, что мы определили обобщенную процедуру возведения в степень:
(define (exp x y) (apply-generic ’exp x y))
и добавили процедуру возведения в степень в пакет чисел Scheme и ни в какой другой:
;; Следующие строки добавляются в пакет scheme-number
(put ’exp ’(scheme-number scheme-number)
(lambda (x y) (tag (expt x y)))) ;используется
;элементарная expt
Что произойдет, если мы позовем exp с двумя комплексными числами в качестве аргументов?
б. Прав ли Хьюго, что нужно что-то сделать с приведением однотипных аргументов, или все и
так работает правильно?
в. Измените apply-generic так, чтобы она не пыталась применить приведение, если у обоих
аргументов один и тот же тип.
Упражнение 2.82.
Покажите, как обобщить apply-generic так, чтобы она обрабатывала приведение в общем
случае с несколькими аргументами. Один из способов состоит в том, чтобы попытаться сначала
привести все аргументы к типу первого, потом к типу второго, и так далее. Приведите пример,
когда эта стратегия (а также двухаргументная версия, описанная выше) недостаточно обща. (Подсказка: рассмотрите случай, когда в таблице есть какие-то подходящие операции со смешанными
типами, но обращения к ним не произойдет.)
Упражнение 2.83.
Предположим, что Вы разрабатываете обобщенную арифметическую систему для работы с башней
типов, показанной на рис. 2.25: целые, рациональные, действительные, комплексные. Для каждого
ориентированных языков программирования — и мелких невразумительных различий между современными
объектно-ориентированными языками, — сосредоточена в том, как рассматриваются обобщенные операции над
взаимосвязанными типами. Наше собственное описание вычислительных объектов в главе 3 полностью избегает этих вопросов. Читатели, знакомые с объектно-ориентированным программированием, заметят, что нам
есть, что сказать в главе 3 о локальном состоянии, но мы ни разу не упоминаем «классы» или «наследование».
Мы подозреваем, что на самом деле эти проблемы нельзя рассматривать только в терминах проектирования
языков программирования, без обращения к работам по представлению знаний и автоматическому логическому
выводу.

198

Глава 2. Построение абстракций с помощью данных

из типов (кроме комплексного), разработайте процедуру, поднимающую объект на один уровень
в башне. Покажите, как ввести обобщенную операцию raise, которая будет работать для всех
типов (кроме комплексных чисел).
Упражнение 2.84.
Используя операцию raise из упражнения 2.83, измените процедуру apply-generic так, чтобы она приводила аргументы к одному типу путем последовательного подъема, как описано в
этом разделе. Потребуется придумать способ проверки, какой из двух типов выше по башне. Сделайте это способом, «совместимым» с остальной системой, так, чтобы не возникало проблем при
добавлении к башне новых типов.
Упражнение 2.85.
В этом разделе упоминался метод «упрощения» объекта данных путем спуска его по башне насколько возможно вниз. Разработайте процедуру drop, которая делает это для башни, описанной
в упражнении 2.83. Ключ к задаче состоит в том, что надо решить некоторым общим способом,
можно ли понизить объект в типе. Например, комплексное число 1.5+0i можно опустить до real,
комплексное число 1 + 0i до integer, а комплексное число 2 + 3i никуда понизить нельзя. Вот
план того, как определить, можно ли понизить объект: для начала определите обобщенную операцию project, которая «сталкивает» объект вниз по башне. Например, проекция комплексного
числа будет состоять в отбрасывании его мнимой части. Тогда число можно сдвинуть вниз в том
случае, если, спроецировав его, а затем подняв обратно до исходного типа, мы получаем нечто,
равное исходному числу. Покажите как реализовать эту идею в деталях, написав процедуру drop,
которая опускает объект как можно ниже. Потребуется разработать различные операции проекции53 и установить project в системе в качестве обобщенной операции. Вам также потребуется
обобщенный предикат равенства, подобный описанному в упражнении 2.79. Наконец, используя
drop, перепишите apply-generic из упражнения 2.84, чтобы она «упрощала» свои результаты.
Упражнение 2.86.
Допустим, нам хочется работать с комплексными числами, чьи действительные и мнимые части,
модули и аргументы могут быть обыкновенными числами, рациональными числами либо любыми
другими, какие нам захочется добавить к системе. Опишите и реализуйте изменения в системе,
которые потребуются, чтобы добавить такую возможность. Вам придется определить операции
вроде sine (синус) и cosine (косинус), обобщенные на обыкновенные и рациональные числа.

2.5.3. Пример: символьная алгебра
Обработка символьных алгебраических выражений представляет собой сложный процесс, который иллюстрирует многие тяжелейшие проблемы, возникающие при проектировании больших систем. В общем случае, алгебраическое выражение можно рассматривать как иерархическую структуру, дерево операций, применяемых к операндам. Мы
можем строить алгебраические выражения, начиная с элементарных объектов, таких,
как константы и переменные, и комбинируя их с помощью алгебраических операций,
таких, как сложение и умножение. Как и в других языках, мы формируем абстракции,
которые позволяют нам именовать составные объекты при помощи простых терминов.
53 Действительное число можно спроецировать на целое при помощи примитива round, который возвращает
целое число, ближайшее к своему аргументу.

2.5. Системы с обобщенными операциями

199

В символьной алгебре типичными абстракциями являются такие понятия, как линейная
комбинация, многочлен, рациональная или тригонометрическая функция. Мы можем
рассматривать их как составные «типы», которые часто бывают полезны при управлении
обработкой выражений. Например, выражение
x2 sin(y 2 + 1) + cos 2y + cos(y 3 − 2y 2 )
можно рассматривать как многочлен по x с коэффициентами, которые являются тригонометрическими функциями многочленов по y, чьи коэффициенты, в свою очередь, целые
числа.
Здесь мы не будем пытаться разработать полную систему для работы с алгебраическими выражениями. Такие системы — очень сложные программы, использующие глубокие математические знания и элегантные алгоритмы. Мы собираемся описать только
одну простую, но важную часть алгебраических операций — арифметику многочленов.
Мы проиллюстрируем типы решений, которые приходится принимать разработчику подобной системы, и то, как применить идеи абстракции данных и обобщенных операций,
чтобы с их помощью организовать работу.
Арифметика многочленов
Первая задача при разработке системы для проведения арифметических операций над
многочленами — решить, что именно представляет собой многочлен. Обычно многочлены
определяют по отношению к тем или иным переменным. Ради простоты, мы ограничимся многочленами только с одной переменной54. Мы определяем многочлен как сумму
термов, каждый из которых представляет собой либо коэффициент, либо переменную,
возведенную в степень, либо произведение того и другого. Коэффициент определяется
как алгебраическое выражение, не зависящее от переменной многочлена. Например,
5x2 + 3x + 7
есть простой многочлен с переменной x, а
(y 2 + 1)x3 + (2y)x + 1
есть многочлен по x, коэффициенты которого — многочлены по y.
Уже здесь мы сталкиваемся с несколькими неудобными деталями. Является ли первый из приведенных многочленов тем же объектом, что 5y 2 + 3y + 7? Разумный ответ на
этот вопрос таков: «если мы рассматриваем многочлен как чисто математическую функцию, то да, но если как синтаксическую форму, то нет». Второй пример алгебраически
эквивалентен многочлену по y, коэффициенты которого — многочлены по x. Должна
ли наша система распознавать это? Наконец, существуют другие способы представления многочленов — например, как произведение линейных множителей, как множество
корней (для многочлена с одной переменной), или как список значений многочлена в за54 С другой стороны, мы разрешаем многочлены, коэффициенты которых сами по себе являются многочленами от других переменных. По существу, это дает нам такую же выразительную силу, что и у полной системы
со многими переменными, хотя и ведет к проблемам приведения, как это обсуждается ниже.

200

Глава 2. Построение абстракций с помощью данных

данном множестве точек55 . Мы можем обойти эти вопросы, решив, что в нашей системе
алгебраических вычислений «многочлен» будет определенной синтаксической формой, а
не ее математическим значением.
Теперь пора подумать, как мы будем осуществлять арифметические операции над
многочленами. В нашей упрощенной системе мы рассмотрим только сложение и умножение. Более того, мы будем настаивать, чтобы два многочлена, над которыми проводится
операция, имели одну и ту же переменную.
К проектированию системы мы приступим, следуя уже знакомой нам дисциплине
абстракции данных. Мы будем представлять многочлены в виде структуры данных под
названием poly, которая состоит из переменной и набора термов. Мы предполагаем, что
имеются селекторы variable и term-list, которые получают из poly эти данные, и
конструктор make-poly, который собирает poly из переменной и списка термов. Переменная будет просто символом, так что для сравнения переменных мы сможем использовать процедуру same-variable? из раздела 2.3.2. Следующие процедуры определяют
сложение и умножение многочленов:
(define (add-poly p1 p2)
(if (same-variable? (variable p1) (variable p2))
(make-poly (variable p1)
(add-terms (term-list p1)
(term-list p2)))
(error "Многочлены от разных переменных -- ADD-POLY"
(list p1 p2))))
(define (mul-poly p1 p2)
(if (same-variable? (variable p1) (variable p2))
(make-poly (variable p1)
(mul-terms (term-list p1)
(term-list p2)))
(error "Многочлены от разных переменных -- MUL-POLY"
(list p1 p2))))

Чтобы включить многочлены в нашу обобщенную арифметическую систему, нам потребуется снабдить их метками типа. Мы будем пользоваться меткой polynomial и
вносить соответствующие операции над помеченными многочленами в таблицу операций. Весь свой код мы включим в процедуру установки пакета многочленов, подобно
пакетам из раздела 2.5.1:
(define (install-polynomial-package)
;; внутренние процедуры
;; представление poly
(define (make-poly variable term-list)
(cons variable term-list))
55 В случае многочленов с одной переменной задание значений многочлена в определенном множестве точек
может быть особенно удачным представлением. Арифметика многочленов получается чрезвычайно простой.
Чтобы получить, скажем, сумму двух представленных таким образом многочленов, достаточно сложить значения в соответствующих точках. Чтобы перейти обратно к более привычному представлению, можно использовать формулу интерполяции Лагранжа, которая показывает, как восстановить коэффициенты многочлена
степени n, имея его значения в n + 1 точке.

2.5. Системы с обобщенными операциями

201

(define (variable p) (car p))
(define (term-list p) (cdr p))
hпроцедуры same-variable? и variable? из раздела 2.3.2i
;; представление термов и списков термов
hпроцедуры adjoin-term ... coeff из текста нижеi
(define (add-poly p1 p2) ... )
hпроцедуры, которыми пользуется

add-polyi

(define (mul-poly p1 p2) ... )
hпроцедуры, которыми пользуется

mul-polyi

;; интерфейс к остальной системе
(define (tag p) (attach-tag ’polynomial p))
(put ’add ’(polynomial polynomial)
(lambda (p1 p2) (tag (add-poly p1 p2))))
(put ’mul ’(polynomial polynomial)
(lambda (p1 p2) (tag (mul-poly p1 p2))))
(put ’make ’polynomial
(lambda (var terms) (tag (make-poly var terms))))
’done)

Сложение многочленов происходит по термам. Термы одинакового порядка (то есть
имеющие одинаковую степень переменной многочлена) нужно скомбинировать. Это делается при помощи порождения нового терма того же порядка, в котором коэффициент
является суммой коэффициентов слагаемых. Термы одного слагаемого, для которых нет
соответствия в другом, просто добавляются к порождаемому многочлену-сумме.
Для того, чтобы работать со списками термов, мы предположим, что имеется конструктор the-empty-termlist, который возвращает пустой список термов, и конструктор adjoin-term, который добавляет к списку термов еще один. Кроме того, мы
предположим, что имеется предикат empty-termlist?, который говорит, пуст ли данный список, селектор first-term, который получает из списка термов тот, у которого
наибольший порядок, и селектор rest-terms, который возвращает все термы, кроме
того, у которого наибольший порядок. Мы предполагаем, что для работы с термами у
нас есть конструктор make-term, строящий терм с указанными порядком и коэффициентом, и селекторы order и coeff, которые, соответственно, возвращают порядок
и коэффициент терма. Эти операции позволяют нам рассматривать и термы, и их списки как абстракции данных, о конкретной реализации которых мы можем позаботиться
отдельно.
Вот процедура, которая строит список термов для суммы двух многочленов56 :
(define (add-terms L1 L2)
(cond ((empty-termlist? L1) L2)
56 Эта операция очень похожа на процедуру объединения множеств union-set, которую мы разработали в
упражнении 2.62. На самом деле, если мы будем рассматривать многочлены как множества, упорядоченные по
степени переменной, то программа, которая порождает список термов для суммы, окажется почти идентична
union-set.

202

Глава 2. Построение абстракций с помощью данных
((empty-termlist? L2) L1)
(else
(let ((t1 (first-term L1)) (t2 (first-term L2)))
(cond ((> (order t1) (order t2))
(adjoin-term
t1 (add-terms (rest-terms L1) L2)))
((< (order t1) (order t2))
(adjoin-term
t2 (add-terms L1 (rest-terms L2))))
(else
(adjoin-term
(make-term (order t1)
(add (coeff t1) (coeff t2)))
(add-terms (rest-terms L1)
(rest-terms L2)))))))))

Самая важная деталь, которую здесь надо заметить, — это что для сложения коэффициентов комбинируемых термов мы использовали обобщенную процедуру add. Это влечет
глубокие последствия, как мы увидим ниже.
Чтобы перемножить два списка термов, мы умножаем каждый терм из первого списка
на все термы второго, используя в цикле mul-term-by-allterms, которая умножает указанный терм на все термы указанного списка. Получившиеся списки термов (по
одному на каждый терм в первом списке) накапливаются и образуют сумму. Перемножение двух термов дает терм, порядок которого равен сумме порядков множителей, а
коэффициент равен произведению коэффициентов множителей:
(define (mul-terms L1 L2)
(if (empty-termlist? L1)
(the-empty-termlist)
(add-terms (mul-term-by-all-terms (first-term L1) L2)
(mul-terms (rest-terms L1) L2))))
(define (mul-term-by-all-terms t1 L)
(if (empty-termlist? L)
(the-empty-termlist)
(let ((t2 (first-term L)))
(adjoin-term
(make-term (+ (order t1) (order t2))
(mul (coeff t1) (coeff t2)))
(mul-term-by-all-terms t1 (rest-terms L))))))

Вот и все, что нам требуется для сложения и умножения многочленов. Обратите
внимание, что, поскольку мы работаем с термами при помощи обобщенных процедур
add и mul, наш пакет работы с многочленами автоматически оказывается в состоянии
обрабатывать любой тип коэффициента, о котором знает обобщенный арифметический
пакет. Если мы подключим механизм приведения типов, подобный тому, который обсуждался в разделе 2.5.2, то мы автоматически окажемся способны производить операции
над многочленами с коэффициентами различных типов, например
2
[3x2 + (2 + 3i)x + 7] · [x4 + x2 + (5 + 3i)]
3

2.5. Системы с обобщенными операциями

203

Поскольку мы установили процедуры сложения и умножения многочленов add-poly
и mul-poly в обобщенной арифметической системе в качестве операций add и mul
для типа polynomial, наша система оказывается автоматически способна производить
операции над многочленами вроде
[(y + 1)x2 + (y 2 + 1)x + (y − 1)] · [(y − 2)x + (y 3 + 7)]
Причина этого в том, что, когда система пытается скомбинировать коэффициенты, она
диспетчирует через add и mul. Поскольку коэффициенты сами по себе являются многочленами (по y), они будут скомбинированы при помощи add-poly и mul-poly. В
результате получается своего рода «рекурсия, управляемая данными», где, например,
вызов mul-poly приводит к рекурсивным вызовам mul-poly для того, чтобы скомбинировать коэффициенты. Если бы коэффициенты коэффициентов сами по себе были бы
многочленами (это может потребоваться, если надо представить многочлены от трех переменных), программирование, управляемое данными, позаботится о том, чтобы система
прошла еще через один уровень рекурсивных вызовов, и так далее, на столько уровней
структуры, сколько требуют данные57 .
Представление списков термов
Наконец, мы сталкиваемся с задачей реализовать хорошее представление для списков термов. Список термов, в сущности, есть множество коэффициентов, проиндексированное порядком терма. Следовательно, любой из методов представления множеств,
описанных в 2.3.3, годится для этой задачи. С другой стороны, наши процедуры addterms и mul-terms всегда обрабатывают списки термов последовательно от наибольшего порядка к наименьшему, так что мы будем использовать некоторую разновидность
упорядоченного представления.
Как нам устроить структуру данных, которая представляет список термов? Одно из
соображений — «плотность» многочленов, с которыми мы будем работать. Многочлен
называется плотным (dense), если в термах с большинством порядков у него ненулевые
коэффициенты. Если же в нем много нулевых коэффициентов, он называется разреженным (sparse). Например,
A : x5 + 2x4 + 3x2 − 2x − 5
плотный многочлен, а
B : x100 + 2x2 + 1
разреженный.
Списки термов плотных многочленов эффективнее всего представлять в виде списков
коэффициентов. Например, A в приведенном примере удобно представляется в виде (1
57 Чтобы все это работало совершенно гладко, потребуется добавить в нашу систему обобщенной арифметики
возможность привести «число» к типу многочлена, рассматривая его как многочлен степени ноль, коэффициентом которого является данное число. Это нужно, если мы хотим осуществлять операции вроде

[x2 + (y + 1)x + 5] + [x2 + 2x + 1]
где требуется сложить коэффициент y + 1 с коэффициентом 2.

204

Глава 2. Построение абстракций с помощью данных

2 0 3 -2 -5). Порядок терма в таком представлении есть длина списка, начинающегося с этого коэффициента, уменьшенная на 158 . Для разреженного многочлена вроде
B такое представление будет ужасным: получится громадный список нулей, в котором
изредка попадаются одинокие ненулевые термы. Более разумно представление разреженного многочлена в виде списка ненулевых термов, где каждый терм есть список, содержащий порядок терма и коэффициент при этом порядке. При такой схеме многочлен B
эффективно представляется в виде ((100 1) (2 2) (0 1)). Поскольку большинство
операций над многочленами применяется к разреженным многочленам, мы используем
это представление. Мы предполагаем, что список термов представляется в виде списка,
элементами которого являются термы, упорядоченные от бо́льшего порядка к меньшему.
После того, как решение принято, реализация селекторов и конструкторов для термов и
списков термов не представляет трудностей59 :
(define (adjoin-term term term-list)
(if (=zero? (coeff term))
term-list
(cons term term-list)))
(define
(define
(define
(define

(the-empty-termlist) ’())
(first-term term-list) (car term-list))
(rest-terms term-list) (cdr term-list))
(empty-termlist? term-list) (null? term-list))

(define (make-term order coeff) (list order coeff))
(define (order term) (car term))
(define (coeff term) (cadr term))

где =zero? работает так, как определяется в упражнении 2.80 (см. также ниже упражнение 2.87).
Пользователи многочленного пакета будут создавать (помеченные) многочлены при
помощи процедуры:
(define (make-polynomial var terms)
((get ’make ’polynomial) var terms))

Упражнение 2.87.
Установите =zero? для многочленов в обобщенный арифметический пакет. Это позволит adjointerm работать с многочленами, чьи коэффициенты сами по себе многочлены.
58 В этих примерах многочленов мы предполагаем, что реализовали обобщенную арифметическую систему
при помощи механизма типов, предложенного в упражнении 2.78. Таким образом, коэффициенты, которые
являются обыкновенными числами, будут представлены самими числами, а не парами с первым элементом —
символом scheme-number.
59 Хотя мы предполагаем, что списки термов упорядочены, мы реализовали adjoin-term путем простого
cons к существующему списку термов. Нам это может сойти с рук, пока мы гарантируем, что процедуры
(вроде add-terms), которые используют adjoin-term, всегда вызывают ее с термом бо́льшего порядка, чем
уже есть в списке. Если бы нам не хотелось давать такую гарантию, мы могли бы реализовать adjoin-term
подобно конструктору adjoin-set для представления множеств в виде упорядоченных списков (упражнение 2.61).

2.5. Системы с обобщенными операциями

205

Упражнение 2.88.
Расширьте систему многочленов так, чтобы она включала вычитание многочленов. (Подсказка:
может оказаться полезным определить обобщенную операцию смены знака.)
Упражнение 2.89.
Определите процедуры, которые реализуют представление в виде списка термов, описанное выше
как подходящее для плотных многочленов.
Упражнение 2.90.
Допустим, что мы хотим реализовать систему многочленов, которая эффективна как для плотных,
так и для разреженных многочленов. Один из способов это сделать заключается в том, чтобы разрешить в системе оба типа представления. Ситуация аналогична примеру с комплексными числами
из раздела 2.4, где мы позволили сосуществовать декартову и полярному представлению. Чтобы
добиться этого, нам придется различать виды списков термов и сделать операции над списками
термов обобщенными. Перепроектируйте систему с многочленами так, чтобы это обобщение было
реализовано. Это потребует большого труда, а не только локальных изменений.
Упражнение 2.91.
Многочлены с одной переменной можно делить друг на друга, получая частное и остаток. Наx5 − 1
= x3 + x, остаток x − 1.
пример, 2
x −1
Деление можно производить в столбик. А именно, разделим старший член делимого на старший член делителя. В результате получится первый терм частного. Затем умножим результат на
делитель, вычтем получившийся многочлен из делимого и, рекурсивно деля разность на делитель,
получим оставшуюся часть частного. Останавливаемся, когда порядок делителя превысит порядок делимого, и объявляем остатком то, что тогда будет называться делимым. Кроме того, если
когда-нибудь делимое окажется нулем, возвращаем ноль в качестве и частного, и остатка.
Процедуру div-poly можно разработать, следуя образцу add-poly и mul-poly. Процедура
проверяет, одна ли и та же у многочленов переменная. Если это так, div-poly откусывает
переменную и передает задачу в div-terms, которая производит операцию деления над списками
термов. Наконец, div-poly прикрепляет переменную к результату, который выдает div-terms.
Удобно сделать так, чтобы div-terms выдавала и частное, и остаток при делении. Она может
брать в качестве аргументов два терма и выдавать список, состоящий из списка термов частного
и списка термов остатка.
Закончите следующее определение div-terms, вставив недостающие выражения. Используйте ее, чтобы реализовать div-poly, которая получает в виде аргументов два экземпляра poly, а
выдает список из poly–частного и poly–остатка.
(define (div-terms L1 L2)
(if (empty-termlist? L1)
(list (the-empty-termlist) (the-empty-termlist))
(let ((t1 (first-term L1))
(t2 (first-term L2)))
(if (> (order t2) (order t1))
(list (the-empty-termlist) L1)
(let ((new-c (div (coeff t1) (coeff t2)))
(new-o (- (order t1) (order t2))))
(let ((rest-of-result
hрекурсивно вычислить оставшуюся

206

Глава 2. Построение абстракций с помощью данных
часть результатаi
))
hсформировать окончательный результатi
))))))

Иерархии типов в символьной алгебре
Наша система обработки многочленов показывает, как объекты одного типа (многочлены) могут на самом деле быть составными сущностями, содержащими в качестве
частей объекты многих различных типов. При определении обобщенных операций это
не составляет никакой реальной сложности. Нужно только установить соответствующие
обобщенные операции для выполнения необходимых действий над частями составных
типов. В сущности, мы видели, что многочлены образуют своего рода «рекурсивную
абстракцию данных», в том смысле, что части многочленов сами по себе могут быть
многочленами. Наши обобщенные операции и наш стиль программирования, управляемого данными, могут справиться с такими трудностями без особого труда.
С другой стороны, алгебра многочленов представляет собой систему, в которой типы
данных нельзя естественным образом выстроить в виде башни. Например, могут существовать многочлены по x, коэффициенты которых являются многочленами по y. Но
могут существовать и многочлены по y, коэффициенты которых являются многочленами
по x. Никакой из этих типов не находится «выше» другого ни в каком естественным
смысле, и тем не менее элементы этих двух множеств часто требуется складывать. Для
этого существует несколько способов. Одна из возможностей состоит в том, чтобы преобразовывать один из многочленов к типу другого путем раскрытия и переупорядочения
термов, так, чтобы у обоих многочленов оказалась одна и та же главная переменная.
Можно навязать данным башнеподобную структуру путем упорядочения переменных,
и, таким образом, всегда преобразовывать любой многочлен к «канонической форме»,
где переменная с наибольшим приоритетом всегда доминирует, а переменные с меньшим оказываются зарыты в коэффициенты. Такая стратегия работает довольно хорошо,
только преобразование может без особой необходимости «раздуть» многочлен, так что
его станет неудобно читать и, возможно, менее эффективно обрабатывать. Для этой
области структура башни определенно не является естественной, как и для любой другой области, где пользователь может изобретать новые типы динамически, используя
старые в различных комбинирующих формах, таких как тригонометрические функции,
последовательности степеней или интегралы.
Не должно вызывать удивления то, что управление приведением типов представляет
серьезную проблему при разработке крупных систем алгебраических манипуляций. Существенная часть сложности таких систем связана с отношениями между различными
типами. В сущности, можно честно признать, что мы до сих пор не до конца понимаем
приведение типов. Мы даже не до конца осознаем понятие типа данных. Однако то, что
мы знаем, дает нам солидные принципы структурирования и модуляризации, которые
помогают в разработке больших систем.
Упражнение 2.92.
Использовав упорядочение переменных, расширьте пакет работы с многочленами так, чтобы сложение и умножение многочленов работало для многочленов с несколькими переменными. (Это не
простая задача!)

2.5. Системы с обобщенными операциями

207

Расширенное упражнение: рациональные функции
Можно расширить обобщенную арифметическую систему и включить в нее рациональные функции (rational functions). Это «дроби», в которых числитель и знаменатель
являются многочленами, например
x+1
x3 + 1
Система должна уметь складывать, вычитать. умножать и делить рациональные функции, а также осуществлять вычисления вроде
x
x3 + 2x2 + 3x + 1
x+1
+
=
x3 + 1 x2 − 1
x4 + x3 − x − 1
(здесь сумма упрощена при помощи сокращения общих множителей. Обычное «перекрестное умножение» дало бы многочлен четвертой степени в числителе и пятой в знаменателе.)
Если мы изменим пакет арифметики рациональных чисел так, чтобы он использовал
обобщенные операции, то он будет делать то, что нам требуется, за исключением задачи
приведения к наименьшему знаменателю.
Упражнение 2.93.
Модифицируйте пакет арифметики рациональных чисел, заставив его пользоваться обобщенными
операциями, но при этом измените make-rat, чтобы она не пыталась сокращать дроби. Проверьте
систему, применив make-rational к двум многочленам, и получив рациональную функцию
(define p1 (make-polynomial ’x ’((2 1)(0 1))))
(define p2 (make-polynomial ’x ’((3 1)(0 1))))
(define rf (make-rational p2 p1))
Сложите теперь rf саму с собой, используя add. Вы увидите, что процедура сложения не приводит
дроби к наименьшему знаменателю.

Приводить дроби многочленов к наименьшему знаменателю мы можем, используя
ту же самую идею, которой мы воспользовались для целых чисел: изменить makerat, чтобы она делила и числитель, и знаменатель на их наибольший общий делитель.
Понятие «наибольшего общего делителя» имеет смысл для многочленов. Более того,
вычислять НОД для многочленов можно с помощью, в сущности, того же алгоритма
Евклида, который работает на целых числах60 . Вот целочисленная версия:
(define (gcd a b)
(if (= b 0)
a
(gcd b (remainder a b))))
60 То, что алгоритм Евклида работает для многочленов, в алгебре формализуется утверждением, что многочлены образуют структуру, называемую Евклидовым кольцом (Euclidean ring). Евклидово кольцо — это
структура, на которой определены сложение, вычитание и коммутативное умножение, а также некоторый
способ сопоставить каждому элементу кольца x «меру» — неотрицательное целое число m(x), обладающую
следующими свойствами: m(xy) ≥ m(x) для любых ненулевых x и y, а также для любых x и y существует q,
такое, что y = qx + r и либо r = 0, либо m(r) < m(x). С абстрактной точки зрения, это все, что нужно, чтобы
доказать, что алгоритм Евклида работает. В случае целых чисел, мера m каждого числа есть его модуль. Для
структуры многочленов мерой служит степень многочлена.

208

Глава 2. Построение абстракций с помощью данных

Взяв ее за основу, мы можем проделать очевидные изменения и определить операцию
извлечения НОД, которая работает на списках термов:
(define (gcd-terms a b)
(if (empty-termlist? b)
a
(gcd-terms b (remainder-terms a b))))

где remainder-terms извлекает компоненту списка, соответствующую остатку, из
списка, который возвращает операция деления списков термов divterms, реализованная в упражнении 2.91.
Упражнение 2.94.
Используя div-terms, напишите процедуру remainder-terms, и с ее помощью определите
gcd-terms, как показано выше. Напишите теперь процедуру gcd-polys, которая вычисляет
НОД двух многочленов. (Процедура должна сообщать об ошибке, если входные объекты являются
многочленами от разных переменных.) Установите в систему обобщенную операцию greatestcommon-divisor, которая для многочленов сводится к gcd-poly, а для обыкновенных чисел к
обыкновенному gcd. В качестве проверки, попробуйте ввести
(define p1 (make-polynomial ’x ’((4 1) (3 -1) (2 -2) (1 2))))
(define p2 (make-polynomial ’x ’((3 1) (1 -1))))
(greatest-common-divisor p1 p2)
и проверьте результат вручную.
Упражнение 2.95.
Пусть P1 , P2 и P3 – многочлены
P1 : x2 − 2x + 1
P2 : 11x2 + 1
P3 : 13x + 5
Теперь пусть Q1 будет произведение P1 и P2 , а Q2 произведение P1 и P3 . При помощи greatestcommon-divisor (упражнение 2.94) вычислите НОД Q1 и Q2 . Обратите внимание, что ответ не
совпадает с P1 . Этот пример вводит в вычисление операции с нецелыми числами, и это создает
сложности для алгоритма вычисления НОД61 . Чтобы понять, что здесь происходит, попробуйте
включить трассировку в gcd-terms при вычислении НОД либо проведите деление вручную.

Проблему, которую демонстрирует упражнение 2.95, можно решить, если мы используем следующий вариант алгоритма вычисления НОД (который работает только для
многочленов с целыми коэффициентами). Прежде, чем проводить деление многочленов
при вычислении НОД, мы умножаем делимое на целую константу, которая выбирается
так, чтобы в процессе деления не возникло никаких дробей. Результат вычисления будет
отличаться от настоящего НОД на целую константу, но при приведении рациональных
функций к наименьшему знаменателю это несущественно; будет проведено деление и
числителя, и знаменателя на НОД, так что константный множитель сократится.
61 В системах вроде MIT Scheme получится многочлен, который на самом деле является делителем Q и Q ,
1
2
но с рациональными коэффициентами. Во многих других реализациях Scheme, где при делении целых чисел
могут получаться десятичные числа ограниченной точности, может оказаться, что мы не получим правильного
делителя.

2.5. Системы с обобщенными операциями

209

Выражаясь более точно, если P и Q — многочлены, определим O1 как порядок P
(то есть порядок его старшего терма), а O2 как порядок Q. Пусть c будет коэффициент
старшего терма Q. В таком случае, можно показать, что если мы домножим P на множитель целости (integerizing factor) c1+O1 −O2 , то получившийся многочлен можно будет
поделить на Q алгоритмом div-terms, получив результат, в котором не будет никаких
дробей. Операция домножения делимого на такую константу, а затем деления, иногда
называется псевдоделением (pseudodivision) P на Q. Остаток такого деления называется
псевдоостатком (pseudoremainder).
Упражнение 2.96.
а. Напишите процедуру pseudoremainder-terms, которая работает в точности как remainderterms, но только прежде, чем позвать div-terms, домножает делимое на множитель целости,
описанный выше. Модифицируйте gcd-terms так, чтобы она использовала pseudoremainderterms, и убедитесь, что теперь в примере из упражнения 2.95 greatest-common-divisor
выдает ответ с целыми коэффициентами.
б. Теперь у НОД целые коэффициенты, но они больше, чем коэффициенты P1 . Измените gcdterms, чтобы она убирала общий множитель из коэффициентов ответа путем деления всех коэффициентов на их (целочисленный) НОД.

Итак, вот как привести рациональную функцию к наименьшему знаменателю:
• Вычислите НОД числителя и знаменателя, используя версию gcd-terms из
упражнения 2.96.
• Когда Вы получаете НОД, домножьте числитель и знаменатель на множитель целости, прежде чем делить на НОД, чтобы при делении не получить дробных коэффициентов. В качестве множителя можно использовать старший коэффициент НОД,
возведенный в степень 1 + O1 − O2 , где O2 – порядок НОД, а O1 — максимум из порядков числителя и знаменателя. Так Вы добьетесь того, чтобы деление числителя и
знаменателя на НОД не привносило дробей.
• В результате этой операции Вы получите числитель и знаменатель с целыми коэффициентами. Обычно из-за всех множителей целости коэффициенты окажутся очень
большими, стало быть, на последнем шаге следует избавиться от лишних множителей,
вычислив (целый) наибольший общий делитель числителя и знаменателя и поделив на
него все термы.
Упражнение 2.97.
а. Реализуйте этот алгоритм как процедуру reduce-terms, которая принимает в качестве
аргументов два списка термов n и d и возвращает список из nn и dd, которые представляют собой
n и d, приведенные к наименьшему знаменателю по вышеописанному алгоритму. Напишите, кроме
того, процедуру reduce-poly, подобную add-poly, которая проверяет, чтобы два poly имели
одну и ту же переменную. Если это так, reduce-poly откусывает эту переменную и передает
оставшуюся часть задачи в reduce-terms, а затем прикрепляет переменную обратно к двум
спискам термов, которые получены из reduce-terms.
б. Определите процедуру, аналогичную reduce-terms, которая делает то, что делала для целых чисел исходная make-rat:
(define (reduce-integers n d)
(let ((g (gcd n d)))
(list (/ n g) (/ d g))))

Глава 2. Построение абстракций с помощью данных

210

и определите reduce как обобщенную операцию, которая вызывает apply-generic и диспетчирует либо к reduce-poly (если аргументы — многочлены), либо к reduce-integers (для
аргументов типа scheme-number). Теперь Вы легко можете заставить пакет рациональной арифметики приводить дроби к наименьшему знаменателю, потребовав от make-rat звать reduce
прежде, чем сочетать данные числитель и знаменатель в процессе порождения рационального числа. Теперь система обрабатывает рациональные выражения и для целых чисел, и для многочленов.
Чтобы проверить программу, попробуйте пример, который приведен в начале этого расширенного
упражнения:
(define
(define
(define
(define

p1
p2
p3
p4

(make-polynomial
(make-polynomial
(make-polynomial
(make-polynomial

’x
’x
’x
’x

’((1
’((3
’((1
’((2

1)(0 1))))
1)(0 -1))))
1))))
1)(0 -1))))

(define rf1 (make-rational p1 p2))
(define rf2 (make-rational p3 p4))
(add rf1 rf2)
Посмотрите, удалось ли Вам получить правильный ответ, правильно приведенный к наименьшему
знаменателю.

Вычисление НОД находится в центре всякой системы, работающей с рациональными
числами. Алгоритм, который мы использовали в тексте, хотя математически он естествен, работает очень медленно. Медлительность эта проистекает отчасти из большого
количества операций деления, а отчасти из огромного размера промежуточных коэффициентов, которые порождаются в ходе псевдоделения. Одна из активно разрабатываемых
областей в теории систем алгебраических манипуляций – построение более быстрых
алгоритмов для вычисления НОД многочленов62.

62 Изящный и чрезвычайно эффективный метод вычисления НОД многочленов был открыт Ричардом Зиппелем (Zippel 1979). Этот метод — вероятностный алгоритм, подобно быстрому тесту на простоту числа,
описанному в главе 1. Книга Зиппеля Zippel 1993 описывает этот метод, а также другие способы нахождения
НОД многочленов.

ГЛАВА 3
МОДУЛЬНОСТЬ,

ОБЪЕКТЫ И СОСТОЯНИЕ

Metabˆllon ‚napaÔetai

(Изменяясь, оно остается неподвижным)
Гераклит

Plus ça change, plus c’est la même chose
Альфонс Карр

В предыдущих главах мы ввели основные элементы, из которых строятся программы.
Мы видели, как элементарные процедуры и элементарные данные, сочетаясь, образуют составные сущности; мы стали понимать, что без абстракции нельзя справиться со
сложностью больших систем. Однако этих инструментов недостаточно для разработки
программ. Для эффективного синтеза программ требуются также организационные принципы, которые помогали бы нам сформулировать общий проект программы. В частности,
нам нужны стратегии для построения больших программ по принципу модульности
(modularity): чтобы программы «естественным» образом делились на логически цельные
куски, которые можно разрабатывать и поддерживать независимо друг от друга.
Существует мощная стратегия разработки, которая особенно хорошо подходит для
построения программ, моделирующих физические системы: воспроизводить в структуре
программы структуру моделируемой системы. Для каждого объекта в системе мы строим
соответствующий ему вычислительный объект. Для каждого действия в системе определяем в рамках нашей вычислительной модели символьную операцию. Используя эту
стратегию, мы надеемся, что расширение нашей модели на новые объекты или действия
не потребует стратегических изменений в программе, а позволит обойтись только добавлением новых символьных аналогов этих объектов или действий. Если наша организация
системы окажется удачной, то для добавления новых возможностей или отладки старых
нам придется работать только с ограниченной частью системы.
Таким образом, способ, которым мы организуем большую программу, в значительной
степени диктуется нашим восприятием моделируемой системы. В этой главе мы исследуем две важных организационных стратегии, которые соответствуют двум достаточно
различным взглядам на мир и структуру систем. Первая из них сосредотачивается на
объектах (objects), и большая система рассматривается как собрание индивидуальных
объектов, поведение которых может меняться со временем. Альтернативная стратегия
строится вокруг потоков (streams) информации в системе, во многом подобно тому, как
в электронике рассматриваются системы обработки сигналов.
Как подход, основанный на объектах, так и подход, основанный на потоках, высвечивают важные вопросы, касающиеся языков программирования. При работе с объектами

212

Глава 3. Модульность, объекты и состояние

нам приходится думать о том, как вычислительный объект может изменяться и при
этом сохранять свою индивидуальность. Из-за этого нам придется отказаться от подстановочной модели вычислений (раздел 1.1.5) в пользу более механистичной и в то же
время менее привлекательной теоретически модели с окружениями (environment model).
Сложности, связанные с объектами, их изменением и индивидуальностью являются фундаментальным следствием из потребности ввести понятие времени в вычислительные
модели. Эти сложности только увеличиваются, когда мы добавляем возможность параллельного выполнения программ. Получить наибольшую отдачу от потокового подхода
удается тогда, когда моделируемое время отделяется от порядка событий, происходящих в компьютере в процессе вычисления. Мы достигнем этого при помощи метода,
называемого задержанными вычислениями (delayed evaluation).

3.1. Присваивание и внутреннее состояние объектов
Обычно мы считаем, что мир состоит из отдельных объектов, и у каждого из них есть
состояние, которое изменяется со временем. Мы говорим, что объект «обладает состоянием», если на поведение объекта влияет его история. Например, банковский счет обладает состоянием потому, что ответ на вопрос «Могу ли я снять 100 долларов?» зависит
от истории занесения и снятия с него денег. Состояние объекта можно описать набором
из одной или более переменных состояния (state variables), которые вместе содержат
достаточно информации, чтобы определить текущее поведение объекта. В простой банковской системе состояние счета можно охарактеризовать его текущим балансом, вместо
того, чтобы запоминать всю историю транзакций с этим счетом.
Если система состоит из многих объектов, они редко совершенно независимы друг
от друга. Каждый из них может влиять на состояние других при помощи актов взаимодействия, связывающих переменные состояния одного объекта с переменными других
объектов. На самом деле, взгляд, согласно которому система состоит из отдельных объектов, полезнее всего в том случае, когда ее можно разделить на несколько подсистем,
в каждой из которых внутренние связи сильнее, чем связи с другими подсистемами.
Такая точка зрения на систему может служить мощной парадигмой для организации
вычислительных моделей системы. Чтобы такая модель была модульной, ее требуется
разделить на вычислительные объекты, моделирующие реальные объекты системы. Каждый вычислительный объект должен содержать собственные внутренние переменные
состояния (local state variables), описывающие состояние реального объекта. Поскольку
объекты в моделируемой системе меняются со временем, переменные состояния соответствующих вычислительных объектов также должны изменяться. Если мы решаем, что
поток времени в системе будет моделироваться временем, проходящим в компьютере, то
нам требуется способ строить вычислительные объекты, поведение которых меняется по
мере выполнения программы. В частности, если нам хочется моделировать переменные
состояния обыкновенными символическими именами в языке программирования, в языке должен иметься оператор присваивания (assignment operator), который позволял бы
изменять значение, связанное с именем.

3.1. Присваивание и внутреннее состояние объектов

213

3.1.1. Внутренние переменные состояния
Чтобы показать, что мы имеем в виду, говоря о вычислительном объекте, состояние которого меняется со временем, давайте промоделируем ситуацию снятия денег с
банковского счета. Воспользуемся для этого процедурой withdraw, которая в качестве
аргумента принимает сумму, которую требуется снять. Если на счету имеется достаточно средств, чтобы осуществить операцию, то withdraw возвращает баланс, остающийся
после снятия. В противном случае withdraw возвращает сообщение «Недостаточно денег на счете». Например, если вначале на счету содержится 100 долларов, мы получим
следующую последовательность результатов:
(withdraw 25)
75
(withdraw 25)
50
(withdraw 60)
"Недостаточно денег на счете"
(withdraw 15)
35

Обратите внимание, что выражение (withdraw 25), будучи вычислено дважды, дает различные результаты. Это новый тип поведения для процедуры. До сих пор все наши
процедуры можно было рассматривать как описания способов вычисления математических функций. Вызов процедуры вычислял значение функции для данных аргументов, и
два вызова одной и той же процедуры с одинаковыми аргументами всегда приводили к
одинаковому результату1 .
При реализации withdraw мы используем переменную balance, которая показывает остаток денег на счете, и определяем withdraw в виде процедуры, которая обращается к этой переменной. Процедура withdraw проверяет, что значение balance не
меньше, чем значение аргумента amount. Если это так, withdraw уменьшает значение balance на amount и возвращает новое значение balance. В противном случае
она возвращает сообщение «Недостаточно денег на счете». Вот определения balance и
withdraw:
(define balance 100)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете"))
1 На самом деле это не совсем правда. Одно исключение — генератор случайных чисел из раздела 1.2.6.
Второе связано с таблицами операций и типов, которые мы ввели в разделе 2.4.3, где значения двух вызовов
get с одними и теми же аргументами зависели от того, какие были в промежутке между ними вызовы put. С
другой стороны, пока мы не ввели присваивание, мы лишены возможности самим создавать такие процедуры.

214

Глава 3. Модульность, объекты и состояние

Значение переменной balance уменьшается, когда мы выполняем выражение
(set! balance (- balance amount))

Здесь используется особая форма set!, синтаксис которой выглядит так:
(set! hимяi hновое-значениеi)

Здесь hимяi — символ, а hновое-значениеi – произвольное выражение. Set! заменяет значение hимениi на результат, полученный при вычислении hнового-значенияi. В
данном случае, мы изменяем balance так, что его новое значение будет результатом
вычитания amount из предыдущего значения balance2 .
Кроме того, withdraw использует особую форму begin, когда проверка if выдает
истину, и требуется вычислить два выражения: сначала уменьшить balance, а затем
вернуть его значение. В общем случае вычисление выражения
(begin hвыражение1i hвыражение2i ... hвыражениеk i)

приводит к последовательному вычислению выражений от hвыражения1i до hвыраженияki,
и значение последнего выражения hвыражениеk i возвращается в качестве значения всей
формы begin3 .
Хотя процедура withdraw и работает так, как мы того хотели, переменная balance
представляет собой проблему. Balance, как она описана выше, является переменной,
определенной в глобальном окружении, и любая процедура может прочитать или изменить ее значение. Намного лучше было бы, если бы balance можно было сделать
внутренней переменной для withdraw, так, чтобы только withdraw имела доступ к
ней напрямую, а любая другая процедура — только посредством вызовов withdraw.
Так можно будет более точно смоделировать представление о balance как о внутренней переменной состояния, с помощью которой withdraw следит за состоянием счета.
Сделать balance внутренней по отношению к withdraw мы можем, переписав определение следующим образом:
(define new-withdraw
(let ((balance 100))
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете"))))

Здесь мы, используя let, создаем окружение с внутренней переменной balance, которой вначале присваивается значение 100. Внутри этого локального окружения мы при
2 Значение выражения set! зависит от реализации. Set! нужно использовать только ради эффекта, который оно оказывает, а не ради значения, которое оно возвращает.
Имя set! отражает соглашение, принятое в Scheme: операциям, которые изменяют значения переменных
(или структуры данных, как мы увидим в разделе 3.3) даются имена с восклицательным знаком на конце. Это
напоминает соглашение называть предикаты именами, которые оканчиваются на вопросительный знак.
3 Неявно мы уже использовали в своих программах begin, поскольку в Scheme тело процедуры может быть
последовательностью выражений. Кроме того, в каждом подвыражении cond следствие может состоять не из
одного выражения, а из нескольких.

3.1. Присваивание и внутреннее состояние объектов

215

помощи lambda определяем процедуру, которая берет в качестве аргумента amount и
действует так же, как наша старая процедура withdraw. Эта процедура — возвращаемая как результат выражения let, — и есть new-withdraw. Она ведет себя в точности
так же, как, как withdraw, но ее переменная balance недоступна для всех остальных
процедур4 .
Set! в сочетании с локальными переменными — общая стратегия программирования,
которую мы будем использовать для построения вычислительных объектов, обладающих
внутренним состоянием. К сожалению, при использовании этой стратегии возникает
серьезная проблема: когда мы только вводили понятие процедуры, мы ввели также подстановочную модель вычислений (раздел 1.1.5) для того, чтобы объяснить, что означает
применение процедуры к аргументам. Мы сказали, что оно должно интерпретироваться
как вычисление тела процедуры, в котором формальные параметры заменяются на свои
значения. К сожалению, как только мы вводим в язык присваивание, подстановка перестает быть адекватной моделью применения процедуры. (Почему это так, мы поймем в
разделе 3.1.3.) В результате, с технической точки зрения мы сейчас не умеем объяснить,
почему процедура new-withdraw ведет себя именно так, как описано выше. Чтобы
действительно понять процедуры, подобные new-withdraw, нам придется разработать
новую модель применения процедуры. В разделе 3.2 мы введем такую модель, попутно объяснив set! и локальные переменные. Однако сначала мы рассмотрим некоторые
вариации на тему, заданную new-withdraw.
Следующая процедура, make-withdraw, создает «обработчики снятия денег со счетов». Формальный параметр balance, передаваемый в make-withdraw, указывает начальную сумму денег на счету5 .
(define (make-withdraw balance)
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете")))

При помощи make-withdraw можно следующим образом создать два объекта W1 и W2:
(define W1 (make-withdraw 100))
(define W2 (make-withdraw 100))
(W1 50)
50
(W2 70)
30
4 По терминологии, принятой при описании языков программирования, переменная balance инкапсулируется (is encapsulated) внутри процедуры new-withdraw. Инкапсуляция отражает общий принцип проектирования систем, известный как принцип сокрытия (the hiding principle): систему можно сделать более модульной
и надежной, если защищать ее части друг от друга; то есть, разрешать доступ к информации только тем частям
системы, которым «необходимо это знать».
5 В отличие от предыдущей процедуры new-withdraw, здесь нам необязательно использовать let, чтобы
сделать balance локальной переменной, поскольку формальные параметры и так локальны. Это станет яснее
после обсуждения модели вычисления с окружениями в разделе 3.2. (См. также упражнение 3.10.)

216

Глава 3. Модульность, объекты и состояние

(W2 40)
"Недостаточно денег на счете"
(W1 40)
10

Обратите внимание, что W1 и W2 — полностью независимые объекты, каждый со своей
локальной переменной balance. Снятие денег с одного счета не влияет на другой.
Мы можем создавать объекты, которые будут разрешать не только снятие денег, но и
их занесение на счет, и таким образом можно смоделировать простые банковские счета.
Вот процедура, которая возвращает объект-«банковский счет» с указанным начальным
балансом:
(define (make-account balance)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете"))
(define (deposit amount)
(set! balance (+ balance amount))
balance)
(define (dispatch m)
(cond ((eq? m ’withdraw) withdraw)
((eq? m ’deposit) deposit)
(else (error "Неизвестный вызов -- MAKE-ACCOUNT"
m))))
dispatch)

Каждый вызов make-account создает окружение с локальной переменной состояния
balance. Внутри этого окружения make-account определяет процедуры deposit
и withdraw, которые обращаются к balance, а также дополнительную процедуру
dispatch, которая принимает «сообщение» в качестве ввода, и возвращает одну из
двух локальных процедур. Сама процедура dispatch возвращается как значение, которое представляет объект-банковский счет. Это не что иное, как стиль программирования
с передачей сообщений (message passing), который мы видели в разделе 2.4.3, но только
здесь мы его используем в сочетании с возможностью изменять локальные переменные.
Make-account можно использовать следующим образом:
(define acc (make-account 100))
((acc ’withdraw) 50)
50
((acc ’withdraw) 60)
"Недостаточно денег на счете"
((acc ’deposit) 40)

3.1. Присваивание и внутреннее состояние объектов

217

90
((acc ’withdraw) 60)
30

Каждый вызов acc возвращает локально определенную процедуру deposit или withdraw,
которая затем применяется к указанной сумме. Точно так же, как это было с makewithdraw, второй вызов make-account
(define acc2 (make-account 100))

создает совершенно отдельный объект-счет, который поддерживает свою собственную
переменную balance.
Упражнение 3.1.
Накопитель (accumulator) — это процедура, которая вызывается с одним численным аргументом
и собирает свои аргументы в сумму. При каждом вызове накопитель возвращает сумму, которую
успел накопить. Напишите процедуру make-accumulator, порождающую накопители, каждый
из которых поддерживает свою отдельную сумму. Входной параметр make-accumulator должен
указывать начальное значение суммы; например,
(define A (make-accumulator 5))
(A 10)
15
(A 10)
25
Упражнение 3.2.
При тестировании программ удобно иметь возможность подсчитывать, сколько раз за время вычислений была вызвана та или иная процедура. Напишите процедуру make-monitored, принимающую в качестве параметра процедуру f, которая сама по себе принимает один входной
параметр. Результат, возвращаемый make-monitored — третья процедура, назовем ее mf, которая подсчитывает, сколько раз она была вызвана, при помощи внутреннего счетчика. Если на
входе mf получает специальный символ how-many-calls?, она возвращает значение счетчика.
Если же на вход подается специальный символ reset-count, mf обнуляет счетчик. Для любого
другого параметра mf возвращает результат вызова f с этим параметром и увеличивает счетчик.
Например, можно было бы сделать отслеживаемую версию процедуры sqrt:
(define s (make-monitored sqrt))
(s 100)
10
(s ’how-many-calls?)
1
Упражнение 3.3.
Измените процедуру make-account так, чтобы она создавала счета, защищенные паролем.
А именно, make-account должна в качестве дополнительного аргумента принимать символ,
например

218

Глава 3. Модульность, объекты и состояние

(define acc (make-account 100 ’secret-password))
Получившийся объект-счет должен обрабатывать запросы, только если они сопровождаются паролем, с которым счет был создан, а в противном случае он должен жаловаться:
((acc ’secret-password ’withdraw) 40)
60
((acc ’some-other-password ’deposit) 50)
"Неверный пароль"
Упражнение 3.4.
Модифицируйте процедуру make-account из упражнения 3.3, добавив еще одну локальную переменную, так, чтобы, если происходит более семи попыток доступа подряд с неверным паролем,
вызывалась процедура call-the-cops (вызвать полицию).

3.1.2. Преимущества присваивания
Как нам предстоит увидеть, введение присваивания в наш язык программирования
ведет к множеству сложных концептуальных проблем. Тем не менее, представление о
системе как о наборе объектов, имеющих внутреннее состояние, — мощное средство для
обеспечения модульности проекта. В качестве примера рассмотрим строение процедуры
rand, которая, будучи вызванной, каждый раз возвращает случайное целое число.
Вовсе не так просто определить, что значит «случайное». Вероятно, имеется в виду,
что последовательные обращения к rand должны порождать последовательность чисел,
которая обладает статистическими свойствами равномерного распределения. Здесь мы
не будем обсуждать способы порождения подобных последовательностей. Вместо этого
предположим, что у нас есть процедура rand-update, которая обладает следующим
свойством: если мы начинаем с некоторого данного числа x1 и строим последовательность
x2 = (rand-update x1 )
x3 = (rand-update x2 )
то последовательность величин x1 , x2 , x3 . . . будет обладать требуемыми математическими свойствами6 .
Мы можем реализовать rand как процедуру с внутренней переменной состояния
x, которая инициализируется некоторым заранее заданным значением random-init.
Каждый вызов rand вычисляет rand-update от текущего значения x, возвращает это
значение как случайное число, и, кроме того, сохраняет его как новое значение x.
6 Один из распространенных способов реализации rand-update состоит в том, чтобы положить новое значение x равным ax + b mod m, где a, b и m — соответствующим образом подобранные целые числа. Глава 3
книги Knuth 1981 содержит подробное обсуждение методов порождения последовательностей случайных чисел
и обеспечения их статистических свойств. Обратите внимание, что rand-update вычисляет математическую
функцию: если ей дважды дать один и тот же вход, она вернет одинаковый результат. Таким образом, последовательность чисел, порождаемая rand-update, никоим образом не «случайна», если мы настаиваем на
том, что в последовательности «случайных» чисел следующее число не должно иметь никакого отношения к
предыдущему. Отношение между «настоящей» случайностью и так называемыми псевдослучайными (pseudorandom) последовательностями, которые порождаются путем однозначно определенных вычислений и тем не
менее обладают нужными статистическими свойствами, — непростой вопрос, связанный со сложными проблемами математики и философии. Для прояснения этих вопросов много сделали Колмогоров, Соломонофф и
Хайтин; обсуждение можно найти в Chaitin 1975.

3.1. Присваивание и внутреннее состояние объектов

219

(define rand
(let ((x random-init))
(lambda ()
(set! x (rand-update x))
x)))

Разумеется, ту же последовательность случайных чисел мы могли бы получить без
использования присваивания, просто напрямую вызывая rand-update. Однако это
означало бы, что всякая часть программы, которая использует случайные числа, должна
явно запоминать текущее значение x, чтобы передать его как аргумент rand-update.
Чтобы понять, насколько это было бы неприятно, рассмотрим использование случайных чисел для реализации т. н. моделирования методом Монте-Карло (Monte Carlo
simulation).
Метод Монте-Карло состоит в том, чтобы случайным образом выбирать тестовые
точки из большого множества и затем делать выводы на основании вероятностей, оцениваемых по результатам тестов. Например, можно получить приближенное значение π,
используя тот факт, что для двух случайно выбранных целых чисел вероятность отсутствия общих множителей (то есть, вероятность того, что их наибольший общий делитель
будет равен 1) составляет 6/π 27 . Чтобы получить приближенное значение π, мы производим большое количество тестов. В каждом тесте мы случайным образом выбираем два
числа и проверяем, не равен ли их НОД единице. Доля тестов, которые проходят, дает
нам приближение к 6/π 2 , и отсюда мы получаем приближенное значение π.
В центре нашей программы находится процедура monte-carlo, которая в качестве
аргументов принимает количество попыток тестирования, а также сам тест — процедуру
без аргументов, возвращающую при каждом вызове либо истину, либо ложь. Montecarlo запускает тест указанное количество раз и возвращает число, обозначающее
долю попыток, в которых тест вернул истинное значение.
(define (estimate-pi trials)
(sqrt (/ 6 (monte-carlo trials cesaro-test))))
(define (cesaro-test)
(= (gcd (rand) (rand)) 1))
(define (monte-carlo trials experiment)
(define (iter trials-remaining trials-passed)
(cond ((= trials-remaining 0)
(/ trials-passed trials))
((experiment)
(iter (- trials-remaining 1) (+ trials-passed 1)))
(else
(iter (- trials-remaining 1) trials-passed))))
(iter trials 0))

Теперь попробуем осуществить то же вычисление, используя rand-update вместо
rand, как нам пришлось бы поступить, если бы у нас не было присваивания для моделирования локального состояния:
7 Эта теорема доказана Э. Чезаро. Обсуждение и доказательство можно найти в разделе 4.5.2 книги Knuth
1981.

220

Глава 3. Модульность, объекты и состояние

(define (estimate-pi trials)
(sqrt (/ 6 (random-gcd-test trials random-init))))
(define (random-gcd-test trials initial-x)
(define (iter trials-remaining trials-passed x)
(let ((x1 (rand-update x)))
(let ((x2 (rand-update x1)))
(cond ((= trials-remaining 0)
(/ trials-passed trials))
((= (gcd x1 x2) 1)
(iter (- trials-remaining 1)
(+ trials-passed 1)
x2))
(else
(iter (- trials-remaining 1)
trials-passed
x2))))))
(iter trials 0 initial-x))

Хотя программа по-прежнему проста, в ней обнаруживается несколько болезненных
нарушений принципа модульности. В первой версии программы нам удалось, используя rand, выразить метод Монте-Карло напрямую как обобщенную процедуру montecarlo, которая в качестве аргумента принимает произвольную процедуру experiment.
Во втором варианте программы, где у генератора случайных чисел нет локального состояния, random-gcd-test приходится непосредственно возиться со случайными числами
x1 и x2 и передавать в итеративном цикле x2 в качестве нового входа rand-update.
Из-за того, что обработка случайных чисел происходит явно, структура накопления результатов тестов начинает зависеть от того, что наш тест использует именно два случайных числа, тогда как для других тестов Монте-Карло может потребоваться, скажем,
одно или три. Даже процедура верхнего уровня estimate-pi вынуждена заботиться о
том, чтобы предоставить начальное значение случайного числа. Поскольку внутренности
генератора случайных чисел просачиваются наружу в другие части программы, задача
изолировать идею метода Монте-Карло так, чтобы применять ее затем к другим задачам, осложняется. В первом варианте программы присваивание инкапсулирует состояние
генератора случайных чисел внутри rand, так что состояние генератора остается независимым от остальной программы.
Общее явление, наблюдаемое на примере с методом Монте-Карло, таково: с точки
зрения одной части сложного процесса кажется, что другие части изменяются со временем. Они обладают скрытым локальным состоянием. Если мы хотим, чтобы структура
программ, которые мы пишем, отражала такое разделение на части, мы создаем вычислительные объекты (например, банковские счета или генераторы случайных чисел), поведение которых изменяется со временем. Состояние мы моделируем при помощи локальных
переменных, а изменение состояния — при помощи присваивания этим переменным.
Здесь возникает соблазн закрыть обсуждение и сказать, что, введя присваивание и
метод сокрытия состояния в локальных переменных, мы обретаем способность структурировать системы более модульным образом, чем если бы нам пришлось всем состоянием
манипулировать явно, с передачей дополнительных параметров. К сожалению, как мы
увидим, все не так просто.

3.1. Присваивание и внутреннее состояние объектов

221

Упражнение 3.5.
Интегрирование методом Монте-Карло (Monte Carlo integration) — способ приближенного вычисления определенных интегралов при помощи моделирования методом Монте-Карло. Рассмотрим задачу вычисления площади фигуры, описываемой предикатом P (x, y), который истинен для
точек (x, y), принадлежащих фигуре, и ложен для точек вне фигуры. Например, область, содержащаяся в круге с радиусом 3 и центром в точке (5, 7), описывается предикатом, проверяющим
(x − 5)2 + (y − 7)2 ≤ 32 . Чтобы оценить площадь фигуры, описываемой таким предикатом, для начала выберем прямоугольник, который содержит нашу фигуру. Например, прямоугольник с углами
(2, 4) и (8, 10), расположенными по диагонали, содержит вышеописанный круг. Нужный нам интеграл — площадь той части прямоугольника, которая лежит внутри фигуры. Мы можем оценить
интеграл, случайным образом выбирая точки (x, y), лежащие внутри прямоугольника, и проверяя
для каждой точки P (x, y), чтобы определить, лежит ли точка внутри фигуры. Если мы проверим
много точек, доля тех, которые окажутся внутри области, даст нам приближенное значение отношения площадей фигуры и прямоугольника. Таким образом, домножив это значение на площадь
прямоугольника, мы получим приближенное значение интеграла.
Реализуйте интегрирование методом Монте-Карло в виде процедуры estimateintegral, которая в качестве аргументов принимает предикат P, верхнюю и нижнюю границы прямоугольника
x1, x2, y1 и y2, а также число проверок, которые мы должны осуществить, чтобы оценить отношение площадей. Ваша процедура должна использовать ту же самую процедуру monte-carlo, которая выше использовалась для оценки значения π. Оцените π при помощи estimate-integral,
измерив площадь единичного круга.
Вам может пригодиться процедура, которая выдает число, случайно выбранное внутри данного
отрезка. Нижеприведенная процедура random-in-range решает эту задачу, используя процедуру
random, введенную в разделе 1.2.6, которая возвращает неотрицательное число меньше своего
аргумента8.
(define (random-in-range low high)
(let ((range (- high low)))
(+ low (random range))))

Упражнение 3.6.
Полезно иметь возможность сбросить генератор случайных чисел, чтобы получить последовательность, которая начинается с некоторого числа. Постройте новую процедуру rand, которая
вызывается с аргументом. Этот аргумент должен быть либо символом generate, либо символом reset. Процедура работает так: (rand ’generate) порождает новое случайное число;
((rand ’reset) hновое-значениеi) сбрасывает внутреннюю переменную состояния в указанное hновое-значениеi. Таким образом, сбрасывая значения, можно получать повторяющиеся
последовательности. Эта возможность очень полезна при тестировании и отладке программ, использующих случайные числа.

3.1.3. Издержки, связанные с введением присваивания
Как мы только что видели, операция set! позволяет моделировать объекты, обладающие внутренним состоянием. Однако за это преимущество приходится платить. Наш
язык программирования нельзя больше описывать при помощи подстановочной модели
8 В MIT Scheme есть такая процедура. Если random на вход дается точное целое число (как в разделе 1.2.6),
она возвращает точное целое число, но если ей дать десятичную дробь (как в этом примере), она и возвращает
десятичную дробь.

222

Глава 3. Модульность, объекты и состояние

применения процедур, которую мы ввели в разделе 1.1.5. Хуже того, не существует
простой модели с «приятными» математическими свойствами, которая бы адекватно описывала работу с объектами и присваивание в языках программирования.
Пока мы не применяем присваивание, два вычисления одной и той же процедуры с
одними и теми же аргументами всегда дают одинаковый результат. Стало быть, можно
считать, что процедуры вычисляют математические функции. Соответственно, программирование, в котором присваивание не используется (как у нас в первых двух главах
этой книги), известно как функциональное программирование (functional programming).
Чтобы понять, как присваивание усложняет ситуацию, рассмотрим упрощенную версию make-withdraw из раздела 3.1.1, которая не проверяет, достаточно ли на счете
денег:
(define (make-simplified-withdraw balance)
(lambda (amount)
(set! balance (- balance amount))
balance))
(define W (make-simplified-withdraw 25))
(W 20)
5
(W 10)
-5

Сравним эту процедуру со следующей процедурой make-decrementer, которая не использует set!:
(define (make-decrementer balance)
(lambda (amount)
(- balance amount)))

make-decrementer возвращает процедуру, которая вычитает свой аргумент из определенного числа balance, но при последовательных вызовах ее действие не накапливается, как при использовании make-simplified-withdraw:
(define D (make-decrementer 25))
(D 20)
5
(D 10)
15

Мы можем объяснить, как работает make-decrementer, при помощи подстановочной
модели. Например, рассмотрим, как вычисляется выражение
((make-decrementer 25) 20)

Сначала мы упрощаем операторную часть комбинации, подставляя в теле make-decrementer
вместо balance 25. Выражение сводится к

3.1. Присваивание и внутреннее состояние объектов

223

((lambda (amount) (- 25 amount)) 20)

Теперь мы применяем оператор к операнду, подставляя 20 вместо amount в теле
lambda-выражения:
(- 25 20)

Окончательный результат равен 5.
Посмотрим, однако, что произойдет, если мы попробуем применить подобный подстановочный анализ к make-simplified-withdraw:
((make-simplified-withdraw 25) 20)

Сначала мы упрощаем оператор, подставляя вместо balance 25 в теле makesimplified-withdraw. Таким образом, наше выражение сводится к9
((lambda (amount) (set! balance (- 25 amount)) 25) 20)

Теперь мы применяем оператор к операнду, подставляя в теле lambda-выражения 20
вместо amount:
(set! balance (- 25 20)) 25

Если бы мы следовали подстановочной модели, нам пришлось бы сказать, что вычисление
процедуры состоит в том, чтобы сначала присвоить переменной balance значение 5, а
затем в качестве значения вернуть 25. Но это дает неверный ответ. Чтобы получить
правильный ответ, нам пришлось бы как-то отличить первое вхождение balance (до
того, как сработает set!) от второго (после выполнения set!). Подстановочная модель
на это не способна.
Проблема здесь состоит в том, что подстановка предполагает, что символы в нашем
языке — просто имена для значений. Но как только мы вводим set! и представление,
что значение переменной может изменяться, переменная уже не может быть всего лишь
именем. Теперь переменная некоторым образом соответствует месту, в котором может
храниться значение, и значение это может меняться. В разделе 3.2 мы увидим, как в
нашей модели вычислений роль этого «места» играют окружения.
Тождественность и изменение
Проблема, который здесь встает, глубже, чем просто поломка определенной модели
вычислений. Как только мы вводим в наши вычислительные модели понятие изменения,
многие другие понятия, которые до сих пор были ясны, становятся сомнительными.
Рассмотрим вопрос, что значит, что две вещи суть «одно и то же».
Допустим, мы два раза зовем make-decrementer с одним и тем же аргументом, и
получаем две процедуры:
(define D1 (make-decrementer 25))
(define D2 (make-decrementer 25))
9 Мы не производим подстановку вхождения balance в выражение set!, поскольку hимяi в set! не
вычисляется. Если бы мы провели подстановку, получилось бы (set! 25 (- 25 amount)), а это не имеет
никакого смысла.

224

Глава 3. Модульность, объекты и состояние

Являются ли D1 и D2 одним и тем же объектом? Можно сказать, что да, поскольку
D1 и D2 обладают одинаковым поведением — каждая из этих процедур вычитает свой
аргумент из 25. В сущности, в любом вычислении можно подставить D1 вместо D2, и
результат не изменится.
Напротив, рассмотрим два вызова make-simplified-withdraw:
(define W1 (make-simplified-withdraw 25))
(define W2 (make-simplified-withdraw 25))

Являются ли W1 и W2 одним и тем же? Нет, конечно, потому что вызовы W1 и W2
приводят к различным результатам, как показывает следующая последовательность вычислений:
(W1 20)
5
(W1 20)
-15
(W2 20)
5

Хотя W1 и W2 «равны друг другу» в том смысле, что оба они созданы вычислением одного и того же выражения (make-simplified-withdraw 25), неверно, что в любом
выражении можно заменить W1 на W2, не повлияв при этом на результат его вычисления.
Язык, соблюдающий правило, что в любом выражении «одинаковое можно подставить вместо одинакового», не меняя его значения, называется референциально прозрачным (referentially transparent). Если мы включаем в свой компьютерный язык set!, его
референциальная прозрачность нарушается. Становится сложно определить, где можно
упростить выражение, подставив вместо него равносильное. Следовательно, рассуждать
о программах, в которых используется присваивание, оказывается гораздо сложнее.
С потерей референциальной прозрачности становится сложно формально описать понятие о том, что два объекта – один и тот же объект. На самом деле, смысл выражения
«то же самое» в реальном мире, который наши программы моделируют, сам по себе
недостаточно ясен. В общем случае, мы можем проверить, являются ли два как будто бы одинаковых объекта одним и тем же, только изменяя один из них и наблюдая,
изменился ли таким же образом и другой. Но как мы можем узнать, «изменился» ли
объект? Только рассмотрев один и тот же объект дважды и проверив, не различается
ли некоторое его свойство между двумя наблюдениями. Таким образом, мы не можем
определить «изменение», не имея заранее понятия «идентичности», а идентичность мы
не можем определить, не рассмотрев результаты изменений.
В качестве примера того, как эти вопросы возникают в программировании, рассмотрим ситуацию, где у Петра и у Павла есть по банковскому счету в 100 долларов. Здесь
не все равно, смоделируем мы это через
(define peter-acc (make-account 100))
(define paul-acc (make-account 100))

или

3.1. Присваивание и внутреннее состояние объектов

225

(define peter-acc (make-account 100))
(define paul-acc peter-acc)

В первом случае, два счета различны. Действия, которые производит Петр, не меняют
счет Павла, и наоборот. Однако во втором случае мы сказали, что paul-acc — это
та же самая вещь, что и peter-acc. Теперь у Петра и у Павла есть совместный
банковский счет, и если Петр возьмет сколько-то с peter-acc, то у Павла на paul-acc
будет меньше денег. При построении вычислительных моделей сходство между этими
двумя несовпадающими ситуациями может привести к путанице. В частности, в случае
с совместным счетом может особенно мешать то, что у одного объекта (банковского
счета) есть два имени (peter-acc и paul-acc); если мы ищем в программе все места,
где может меняться paul-acc, надо смотреть еще и где меняется peter-acc10 .
В связи с этими замечаниями обратите внимание на то, что если бы Петр и Павел
могли только проверять свой платежный баланс, но не менять его, то вопрос «один ли у
них счет?» не имел бы смысла. В общем случае, если мы никогда не меняем объекты данных, то можно считать, что каждый объект представляет собой в точности совокупность
своих частей. Например, рациональное число определяется своим числителем и знаменателем. Однако при наличии изменений такой взгляд становится ошибочным, поскольку
теперь у каждого объекта есть «индивидуальность», которая отличается от тех частей,
из которых он состоит. Банковский счет останется «тем же самым» счетом, даже если
мы снимем с него часть денег; и наоборот, можно иметь два разных счета с одинаковым
состоянием. Такие сложности — следствие не нашего языка программирования, а нашего
восприятия банковского счета как объекта. Скажем, рациональное число мы обычно не
рассматриваем как изменяемый объект со своей индивидуальностью, у которого можно
было бы изменить числитель и по-прежнему иметь дело с «тем же» числом.
Ловушки императивного программирования
В противоположность функциональному программированию, стиль программирования, при котором активно используется присваивание, называется императивное программирование (imperative programming). Кроме того, что возникают сложности с вычислительными моделями, программы, написанные в императивном стиле, подвержены
таким ошибкам, которые в функциональных программах не возникают. Вспомним, к
примеру, итеративную программу для вычисления факториала из раздела 1.2.1:
(define (factorial n)
(define (iter product counter)
(if (> counter n)
product
(iter (* counter product)
10 Когда у вычислительного объекта имеется несколько имён, эти имена называются псевдонимами (aliasing).
Ситуация с совместным банковским счетом — простой пример псевдонимов. В разделе 3.3 мы увидим значительно более сложные примеры, скажем, «различные» составные структуры с общими частями. Если мы
забудем, что «побочным эффектом» в результате изменения одного объекта может стать изменение «другого»
объекта, поскольку «разные» объекты — на самом деле один и тот же под разными псевдонимами, то могут
возникнуть ошибки. Эти так называемые ошибки побочных эффектов (side-effect bugs) настолько трудно обнаруживать и анализировать, что некоторые исследователи выступали с предложениями не допускать в языках
программирования побочные эффекты и псевдонимы (Lampson et al. 1981; Morris, Schmidt, and Wadler 1980).

Глава 3. Модульность, объекты и состояние

226
(+ counter 1))))
(iter 1 1))

Вместо того, чтобы передавать аргументы во внутреннем итеративном цикле, мы могли
бы написать процедуру в более императивном стиле с использованием присваивания для
обновления значений переменных product и counter:
(define (factorial n)
(let ((product 1)
(counter 1))
(define (iter)
(if (> counter n)
product
(begin (set! product (* counter product))
(set! counter (+ counter 1))
(iter))))
(iter)))

Результаты, выдаваемые программой, при этом не меняются, но возникает маленькая ловушка. Как определить порядок присваиваний? В имеющемся виде программа корректна.
Однако если бы мы записали присваивания в обратном порядке:
(set! counter (+ counter 1))
(set! product (* counter product))

— получился бы другой, неверный результат. Вообще, программирование с использованием присваивания заставляет нас тщательно следить за порядком присваиваний, так,
чтобы в каждом использовалась правильная версия значения переменных, которые меняются. В функциональных программах такие сложности просто не возникают11 .
Сложность императивных программ еще увеличивается, если мы начинаем рассматривать приложения, где одновременно выполняется несколько процессов. К этому мы
еще вернемся в разделе 3.4. Однако сначала мы обратимся к задаче построения вычислительной модели для выражений, содержащих присваивание, а также изучим, как
использовать объекты с локальным состоянием при проектировании моделирующих программ.
Упражнение 3.7.
Рассмотрим объекты-банковские счета, создаваемые процедурой make-account, и снабженные
паролями, как это описано в упражнении 3.3. Предположим, что наша банковская система требует от нас умения порождать совместные счета. Напишите процедуру make-joint, которая это
делает. Make-joint должна принимать три аргумента. Первый из них — защищенный паролем
11 Поэтому странно и смешно, что вводные курсы программирования часто читаются в глубоко императивном
стиле. Может быть, сказываются остатки распространенного в 60-е и 70-е годы представления, что программы,
которые вызывают процедуры, непременно будут менее эффективны, чем те, которые производят присваивания. (Steele 1977 развенчивает этот аргумент.) С другой стороны, возможно, считается, что новичкам легче
представить пошаговое присваивание, чем вызов процедуры. Так или иначе, программистам часто приходится
заботиться о вопросе «присвоить сначала эту переменную или ту?», а это усложняет программирование и
затемняет важные идеи.

3.2. Модель вычислений с окружениями

227

счет. Второй обязан совпадать с паролем, с которым этот счет был создан, иначе make-joint
откажется работать. Третий аргумент — новый пароль. Например, если банковский счет peteraccount был создан с паролем open-sesame, то
(define paul-acc
(make-joint peter-acc ’open-sesame ’rosebud))
позволит нам проводить операции с peter-account, используя имя paul-acc и пароль
rosebud. Вам может потребоваться переработать решение упражнения 3.3, чтобы добавить эту
новую возможность.
Упражнение 3.8.
Когда в разделе 1.1.3 мы определяли модель вычислений, мы сказали, что первым шагом при
вычислении выражения является вычисление его подвыражений. Однако мы нигде не указали
порядок, в котором проходит вычисление подвыражений (слева направо или справа налево). Когда
мы вводим присваивание, порядок, в котором вычисляются аргументы процедуры, может повлиять на результат. Определите простую процедуру f, так, чтобы вычисление (+ (f 0) (f 1))
возвращало 0, если аргументы + вычисляются слева направо, и 1, если они вычисляются справа
налево.

3.2. Модель вычислений с окружениями
Когда в главе 1 мы вводили понятие составной процедуры, то для того, чтобы определить, что значит применение процедуры к аргументам, мы пользовались подстановочной
моделью вычислений (раздел 1.1.5):
• Чтобы применить составную процедуру к аргументам, нужно вычислить тело процедуры, подставив вместо каждого формального параметра соответствующий ему
аргумент.
Как только мы вводим в язык программирования присваивание, это определение перестает быть адекватным. А именно, в разделе 3.1.3 указывалось, что в присутствии
присваивания переменную уже нельзя рассматривать просто как имя для значения. Переменная должна каким-то образом обозначать «место», где значение может храниться.
В нашей новой модели вычислений такие места будут находиться в структурах, которые
мы называем окружениями (environments).
Окружение представляет собой последовательность кадров (frames). Каждый кадр
есть (возможно, пустая) таблица связываний (bindings), которые сопоставляют имена
переменных соответствующим значениям. (Каждый кадр должен содержать не более одного связывания для каждой данной переменной.) Кроме того, в каждом кадре имеется
указатель на объемлющее окружение (enclosing environment), кроме тех случаев, когда в
рамках текущего обсуждения окружение считается глобальным (global). Значение переменной (value of a variable) по отношению к данному окружению есть значение, которое
находится в связывании для этой переменной в первом кадре окружения, содержащем
такое связывание. Если в последовательности кадров ни один не указывает значения для
данной переменной, говорят, что переменная несвязана (unbound) в окружении.
На рисунке 3.1 изображена простая структура окружений, которая состоит из трех
кадров, помеченных числами I, II и III. На этой диаграмме A, B, C и D — указатели

Глава 3. Модульность, объекты и состояние

228

x:3
y:5
C
z:6
x:7

A

II

I

D
m:1
y:2

III

B

Рис. 3.1. Простой пример структуры окружений

на окружения. C и D указывают на одно и то же окружение. В кадре II связываются
переменные z и x, а в кадре I переменные y и x. В окружении D переменная x имеет
значение 3. В окружении B значение переменной x также равно 3. Это определяется
следующим образом: мы рассматриваем первый кадр в последовательности (кадр III) и
не находим там связывания для переменной x, так что мы переходим к объемлющему окружению D и находим связывание в кадре I. С другой стороны, в окружении A
значение переменной x равно 7, поскольку первый кадр окружения (кадр II) содержит
связывание x со значением 7. По отношению к окружению A говорится, что связывание
x со значением 7 в кадре II скрывает (shadows) связывание x со значением 3 в кадре I.
Окружение играет важную роль в процессе вычисления, поскольку оно определяет контекст, в котором выражение должно вычисляться. В самом деле, можно сказать, что выражения языка программирования сами по себе не имеют значения. Выражение приобретает значение только по отношению к окружению, в контексте которого оно вычисляется. Даже интерпретация столь простого выражения, как (+ 1 1), зависит от нашего понимания, что мы работаем в контексте, где + является символом сложения. Таким образом, в нашей
модели мы всегда будем говорить о вычислении выражения относительно некоторого окружения. При описании взаимодействия с интерпретатором мы будем
предполагать, что существует глобальное окружение, состоящее из одного кадра (без объемлющего окружения), и что глобальное окружение содержит значения для символов, обозначающих элементарные процедуры. Например, информация о том, что + служит символом сложения, выражается как утверждение,
что в глобальном окружении символ + связан с элементарной процедурой сложения.

3.2.1. Правила вычисления
Общее описание того, как интерпретатор вычисляет комбинацию, остается таким же,
как оно было введено в разделе 1.1.3:

3.2. Модель вычислений с окружениями

229

• Для того, чтобы вычислить комбинацию, нужно:
– Вычислить подвыражения комбинации12 .

– Применить значение выражения-оператора к значениям выражений-операндов.
Модель вычисления с окружениями заменяет подстановочную модель, по-своему определяя, что значит применить составную процедуру к аргументам.
В модели вычисления с окружениями процедура всегда представляется в виде пары,
состоящей из кода и указателя на некое окружение. Процедура создается единственным
способом: вычислением lambda-выражения. Такое вычисление дает в качестве результата процедуру, код которой берется из тела lambda-выражения, а окружение совпадает с
окружением, в котором было вычислено выражение, чьим значением является процедура.
Например, рассмотрим определение процедуры
(define (square x)
(* x x))

которое вычисляется в глобальном окружении. Синтаксис определения процедуры —
всего лишь синтаксический сахар для подразумеваемой lambda. С тем же успехом
можно было написать выражение
(define square
(lambda (x) (* x x)))

которое вычисляет (lambda (x) (* x x)) и связывает символ square с полученным
значением, все это в глобальном окружении.
Рис. 3.2 показывает результат вычисления lambda-выражения. Объект-процедура
представляет собой пару, код которой указывает, что процедура принимает один формальный параметр, а именно x, а тело ее (* x x). Окружение процедуры — это
указатель на глобальное окружение, поскольку именно в нем вычислялось lambdaвыражение, при помощи которого процедура была порождена. К глобальному кадру добавилось новое связывание, которое сопоставляет процедурный объект символу square.
В общем случае define создает определения, добавляя новые связывания в кадры.
Теперь, когда мы рассмотрели, как процедуры создаются, мы можем описать, как
они применяются. Модель с окружениями говорит: чтобы применить процедуру к аргументам, создайте новое окружение, которое содержит кадр, связывающий параметры со
значениями аргументов. Объемлющим окружением для нового кадра служит окружение,
на которое указывает процедура. Теперь требуется выполнить тело процедуры в этом
новом окружении.
Чтобы проиллюстрировать, как работает это новое правило, на рис. 3.3 показана
структура окружений, создаваемая при вычислении выражения (square 5) в глобальном окружении, если square — процедура, порожденная на рисунке 3.2. Применение
12 Присваивание вносит одну тонкость в шаг 1 правила вычисления. Как показано в упражнении 3.8, присваивание позволяет нам писать выражения, которые имеют различные значения в зависимости от того, в каком
порядке вычисляются подвыражения комбинации. Таким образом, чтобы быть точными, мы должны были
бы указать порядок вычислений на шаге 1 (например, слева направо или справа налево). Однако этот порядок всегда должен рассматриваться как деталь реализации, и писать программы, которые зависят от порядка
вычисления аргументов, не следует. К примеру, продвинутый компилятор может оптимизировать программу,
изменяя порядок, в котором вычисляются подвыражения.

Глава 3. Модульность, объекты и состояние

230
глобальное
окружение

другие переменные
square:

(define (square x)
(* x x))

параметры: x
тело: (* x x)
Рис. 3.2. Структура окружений, порождаемая вычислением (define (square x) (*
x x)) в глобальном окружении.
другие переменные
глобальное
окружение square:
(square 5)
E1
параметры: x
тело: (* x x)

x:5

(* x x)

Рис. 3.3. Окружение, создаваемое при вычислении (square 5) в глобальном окружении.
процедуры приводит к созданию нового окружения, которое на рисунке обозначено как
E1, и это окружение начинается с кадра, в котором x, формальный параметр процедуры,
связан с аргументом 5. Указатель, который ведет из этого кадра вверх, показывает, что
объемлющим для этого окружения является глобальное. Глобальное окружение выбирается потому, что именно на него ссылается процедурный объект square. Внутри E1 мы
вычисляем тело процедуры, (* x x). Поскольку значение x в E1 равно 5, результатом
будет (* 5 5), или 25.
Модель вычисления с окружениями можно вкратце описать двумя правилами:
• Процедурный объект применяется к набору аргументов при помощи создания кадра, связывания формальных параметров процедуры с аргументами вызова, и, наконец,
вычисления тела процедуры в контексте этого свежесозданного окружения. В качестве
объемлющего окружения новый кадр имеет окружение, содержащееся в применяемом
процедурном объекте.

3.2. Модель вычислений с окружениями

231

• Процедура создается при вычислении lambda-выражения по отношению к некоторому окружению. Получающийся процедурный объект есть пара, состоящая из текста
lambda-выражения и указателя на окружение, в котором процедура была создана.
Кроме того, мы указываем, что когда символ определяется при помощи define,
в текущем кадре окружения создается связывание, и символу присваивается указанное значение13. Наконец, мы описываем поведение set!, операции, из-за которой нам,
собственно, и пришлось ввести модель с окружениями. Вычисление выражения (set!
hпеременнаяi hзначениеi) в некотором окружении заставляет интерпретатор найти
связывание переменной в окружении и изменить это связывание так, чтобы оно указывало на новое значение. А именно, нужно найти первый кадр окружения, в котором
содержится связывание для переменной, и изменить этот кадр. Если переменная в окружении не связана, set! сигнализирует об ошибке.
Все эти правила вычисления, хотя они значительно сложнее, чем в подстановочной
модели, достаточно просты. Более того, модель вычислений, несмотря на свою абстрактность, дает правильное описание того, как интерпретатор вычисляет выражения. В главе 4 мы увидим, как эта модель может служить основой для реализации работающего
интерпретатора. В последующих разделах анализ нескольких примеров программ раскрывает некоторые детали этой модели.

3.2.2. Применение простых процедур
Когда в разделе 1.1.5 мы описывали подстановочную модель, мы показали, как вычисление комбинации (f 5) дает результат 136, если даны следующие определения:
(define (square x)
(* x x))
(define (sum-of-squares x y)
(+ (square x) (square y)))
(define (f a)
(sum-of-squares (+ a 1) (* a 2)))

Теперь мы можем проанализировать тот же самый пример, используя модель с окружениями. На рисунке 3.4 изображены три процедурных объекта, созданные вычислением
в глобальном окружении определений f, square, и sum-of-squares. Каждый процедурный объект состоит из куска кода и указателя на глобальное окружение.
На рисунке 3.5 мы видим структуру окружений, созданную вычислением выражения
(f 5). Вызов f создает новое окружение E1, начинающееся с кадра, в котором a,
формальный параметр f, связывается с аргументом 5. В окружении E1 мы вычисляем
тело f:
(sum-of-squares (+ a 1) (* a 2))
13 Если в текущем кадре уже имелось связывание для указанной переменной, то это связывание изменяется. Это правило удобно, поскольку позволяет переопределять символы; однако оно означает, что при помощи
define можно изменять значение символов, а это влечет за собой все проблемы, связанные с присваиванием,
без явного использования set!. По этой причине некоторые предпочитают, чтобы переопределение существующего символа вызывало предупреждение или сообщение об ошибке.

Глава 3. Модульность, объекты и состояние

232

sum-of-squares:
глобальное
окружение square:
f:

параметры: a
тело: (sum-of-squares
(+ a 1)
(+ a 2))

параметры: x
тело: (= x x)

параметры: x, y
тело: (+ (square x)
(square y))

Рис. 3.4. Процедурные объекты в глобальном кадре окружения.

глобальное
окружение

E1

a:5 E2

(sum-of-squares
(+ a 1)
(+ a 2))

x:6
y:10

E3

(+ (square x)
(square y))

x:6

E4

(* x x)

x:10
(* x x)

Рис. 3.5. Окружения, созданные при вычислении (f 5) с использованием процедур,
изображенных на рис. 3.4

3.2. Модель вычислений с окружениями

233

Для вычисления этой комбинации сначала мы вычисляем подвыражения. Значение
первого подвыражения, sum-of-squares — процедурный объект. (Обратите внимание,
как мы находим этот объект: сначала мы просматриваем первый кадр E1, который не
содержит связывания для переменной sum-of-squares. Затем мы переходим в объемлющее окружение, а именно глобальное, и там находим связывание, которое показано на
рис. 3.4.) В оставшихся двух подвыражениях элементарные операции + и * применяются
при вычислении комбинаций (+ a 1) и (* a 2), и дают, соответственно, результаты
6 и 10.
Теперь мы применяем процедурный объект sum-of-squares к аргументам 6 и 10.
При этом создается новое окружение E2, в котором формальные параметры x и y связываются со значениями аргументов. Внутри E2 мы вычисляем комбинацию (+ (square
x) (square y)). Для этого нам требуется вычислить (square x), причем значение
square мы находим в глобальном окружении, а x равен 6. Мы опять создаем новое
окружение, E3, где x связан со значением 6, и где мы вычисляем тело square, то есть
(* x x). Кроме того, как часть вычисления sum-of-squares, нам нужно вычислить
подвыражение (square y), где y равен 10. Этот второй вызов square создает еще
одно окружение E4, в котором x, формальный параметр square, связан со значением
10. Внутри E4 нам нужно вычислить (* x x).
Важно заметить, что каждый вызов square создает новое окружение с новым связыванием для x. Теперь мы видим, как разделение кадров служит для того, чтобы разные
локальные переменные по имени x не смешивались. Заметим, кроме того, что все кадры,
созданные процедурой square, указывают на глобальное окружение, поскольку указатель именно на это окружение содержится в процедурном объекте square.
После того, как подвыражения вычисляются, они возвращают значения. Значения,
порожденные двумя вызовами square, складываются в sum-ofsquares, и этот результат возвращается процедурой f. Поскольку сейчас наше внимание сосредоточено
на структурах окружений, мы не будем здесь разбираться, как значения передаются от
вызова к вызову; однако на самом деле это важная часть процесса вычисления, и мы
детально рассмотрим ее в главе 5.
Упражнение 3.9.
В разделе 1.2.1 мы с помощью подстановочной модели анализировали две процедуры вычисления
факториала, рекурсивную
(define (factorial n)
(if (= n 1)
1
(* n (factorial (- n 1)))))
и итеративную
(define (factorial n)
(fact-iter 1 1 n))
(define (fact-iter product counter max-count)
(if (> counter max-count)
product
(fact-iter (* counter product)
(+ counter 1)
max-count)))

234

Глава 3. Модульность, объекты и состояние

Продемонстрируйте, какие структуры окружений возникнут при вычислении (factorial 6) с
каждой из версий процедуры factorial14.

3.2.3. Кадры как хранилище внутреннего состояния
Теперь мы можем обратиться к модели с окружениями и рассмотреть, как можно
с помощью процедур и присваивания представлять объекты, обладающие внутренним
состоянием. В качестве примера возьмем «обработчик снятия денег со счета» из раздела 3.1.1, который создается вызовом процедуры
(define (make-withdraw balance)
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете")))

Опишем вычисление
(define W1 (make-withdraw 100))

за которым следует
(W1 50)

На рисунке 3.6 показан результат определения make-withdraw в глобальном окружении. Получается процедурный объект, который содержит ссылку на глобальное окружение. До сих пор мы не видим особых отличий от тех примеров, которые мы уже
рассмотрели, кроме того, что тело процедуры само по себе является лямбда-выражением.
Интересная часть вычисления начинается тогда, когда мы применяем процедуру
make-withdraw к аргументу:
(define W1 (make-withdraw 100))

Сначала, как обычно, мы создаем окружение E1, где формальный параметр balance связан с аргументом 100. Внутри этого окружения мы вычисляем тело make-withdraw, а
именно lambda-выражение. При этом создается новый процедурный объект, код которого определяется lambda-выражением, а окружение равно E1, окружению, в котором
вычисляется lambda при создании процедуры. Полученный процедурный объект возвращается в качестве значения процедуры make-withdraw. Это значение присваивается
переменной W1 в глобальном окружении, поскольку выражение define вычисляется
именно в нем. Получившаяся структура окружений изображена на рисунке 3.7.
Теперь можно проанализировать, что происходит, когда W1 применяется к аргументу:
(W1 50)
50
14 Модель с окружениями неспособна проиллюстрировать утверждение из раздела 1.2.1, что интерпретатор
может, используя хвостовую рекурсию, вычислять процедуры, подобные fact-iter, в фиксированном объеме
памяти. Мы рассмотрим хвостовую рекурсию, когда будем изучать управляющую структуру интерпретатора в
разделе 5.4.

3.2. Модель вычислений с окружениями

235

глобальное make-withdraw:
окружение

параметры: balance
тело: (lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете"))
Рис. 3.6. Результат определения make-withdraw в глобальном окружении.

make-withdraw:...
глобальное
окружение W1:

E1

balance: 100
параметры: balance
тело: ...

параметры: amount
тело: (if (>= balance amount)
(begin (set! balance (-balance amount))
balance)
"Недостаточно денег на счете"))
Рис. 3.7. Результат вычисления (define W1 (make-withdraw 100)).

Глава 3. Модульность, объекты и состояние

236

make-withdraw:...
глобальное
окружение W1:

E1

balance: 100

Баланс, который будет
изменен операцией
.
set!.

amount: 50
параметры: amount

(if (>= balance amount)
(begin
(set! (balance
(- balance amount))
balance
"Недостаточно денег на счете"))

Рис. 3.8. Окружения, создаваемые при применении процедурного объекта W1.

Для начала мы конструируем кадр, в котором amount, формальный параметр W1, связывается со значением 50. Здесь крайне важно заметить, что у этого кадра в качестве
объемлющего окружения выступает не глобальное окружение, а E1, поскольку именно
на него указывает процедурный объект W1. В этом новом окружении мы вычисляем тело
процедуры:
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете")

Получается структура окружений, изображенная на рисунке 3.8. Вычисляемое выражение обращается к переменным amount и balance. Amount находится в первом кадре
окружения, а balance мы найдем, проследовав по указателю на объемлющее окружение E1.
Когда выполняется set!, связывание переменной balance в E1 изменяется. После
завершения вызова W1 значение balance равно 50, а W1 по-прежнему указывает на
кадр, который содержит переменную balance. Кадр, содержащий amount (тот, в котором мы выполняли код, изменяющий balance), больше не нужен, поскольку создавший
его вызов процедуры закончен, и никаких указателей на этот кадр из других частей
окружения нет. В следующий раз, когда мы позовем W1, создастся новый кадр, в котором будет связана переменная amount, и для которого объемлющим окружением снова
будет E1. Мы видим, что E1 служит «местом», в котором хранится локальная переменная окружения для процедурного объекта W1. На рисунке 3.9 изображена ситуация

3.2. Модель вычислений с окружениями

237

глобальное make-withdraw:...
окружение W1:

E1

balance:50

параметры: amount
тело: ...
Рис. 3.9. Окружения после вызова W1.

после вызова W1.
Рассмотрим, что произойдет, когда мы создадим другой объект для «снятия денег»,
вызвав make-withdraw второй раз:
(define W2 (make-withdraw 100))

При этом получается структура окружений, изображенная на рисунке 3.10. Мы видим,
что W2 — процедурный объект, то есть пара, содержащая код и окружение. Окружение
E2 для W2 было создано во время вызова make-withdraw. Оно содержит кадр со своим
собственным связыванием переменной balance. С другой стороны, код у W1 и W2 один
и тот же: это код, определяемый lambda-выражением в теле make-withdraw15. Отсюда
мы видим, почему W1 и W2 ведут себя как независимые объекты. Вызовы W1 работают
с переменной состояния balance, которая хранится в E1, а вызовы W2 с переменной
balance, хранящейся в E2. Таким образом, изменения внутреннего состояния одного
объекта не действуют на другой.
Упражнение 3.10.
В процедуре make-withdraw локальная переменная balance создается в виде параметра makewithdraw. Можно было бы создать локальную переменную и явно, используя let, а именно:
(define (make-withdraw initial-amount)
(let ((balance initial-amount))
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете"))))
15 Разделяют ли W1 и W2 общий физический код, хранимый в компьютере, или каждый из них хранит
собственную копию кода — это деталь реализации. В интерпретаторе, который мы создадим в главе 4, код
будет общим.

Глава 3. Модульность, объекты и состояние

238

make-withdraw:
...
глобальное
W2:
окружение
W1:

E1

balance: 50

E2

balance: 100

параметры: amount
тело: ...
Рис. 3.10. Создание второго объекта при помощи (define W2 (make-withdraw
100))

Напомним, что в разделе 1.3.2 говорится, что let всего лишь синтаксический сахар для вызова
процедуры:
(let ((hперi hвырi))
hтелоi)
интерпретируется как альтернативный синтаксис для
((lambda (hперi) hтелоi) hвырi)
С помощью модели с окружениями проанализируйте альтернативную версию makewithraw. Нарисуйте картинки, подобные приведенным в этом разделе, для выражений
(define W1 (make-withdraw 100))
(W1 50)
(define W2 (make-withdraw 100))
Покажите, что две версии make-withdraw создают объекты с одинаковым поведением. Как различаются структуры окружений в двух версиях?

3.2.4. Внутренние определения
В разделе 1.1.8 мы познакомились с идеей, что процедуры могут содержать внутренние определения, в результате чего возникает блочная структура, как, например, в
следующей процедуре вычисления квадратного корня:
(define (sqrt x)
(define (good-enough? guess)
(< (abs (- (square guess) x)) 0.001))

3.2. Модель вычислений с окружениями
глобальное
окружение

239

sqrt:

x:2
good-enough?:
improve:...
sqrt-iter:...

E1
параметры: x
тело: (define good-enough? ...)
(define improve ...)
(define sqrt-iter ...)
(sqrt-iter 1.0)
guess:1
E2

вызов sqrt-iter
E3

параметры: guess
тело: (< (abs ...)
...)

guess:1

вызов good-enough?
Рис. 3.11. Процедура sqrt с внутренними определениями.

(define (improve guess)
(average guess (/ x guess)))
(define (sqrt-iter guess)
(if (good-enough? guess)
guess
(sqrt-iter (improve guess))))
(sqrt-iter 1.0))

Теперь с помощью модели с окружениями мы можем увидеть, почему эти внутренние
определения работают так, как должны. На рисунке 3.11 изображен момент во время вычисления выражения (sqrt 2), когда внутренняя процедура good-enough? вызвана
в первый раз со значением guess, равным 1.
Рассмотрим структуру окружения. Символ sqrt в глобальном окружении связан с
процедурным объектом, ассоциированное окружение которого — глобальное окружение.
Когда мы вызвали процедуру sqrt, появилось окружение E1, зависимое от глобального,
в котором параметр x связан со значением 2. Затем мы вычислили тело sqrt внутри
E1. Поскольку первое выражение в теле sqrt есть
(define (good-enough? guess)
(< (abs (- (square guess) x)) 0.001))

вычисление этого выражения привело к определению процедуры good-enough? в окружении E1. Выражаясь более точно, к первому кадру E1 был добавлен символ goodenough?, связанный с процедурным объектом, ассоциированным окружением которого является E1. Подобным образом в качестве процедур внутри E1 были определены

240

Глава 3. Модульность, объекты и состояние

improve и sqrt-iter. Краткости ради на рис. 3.11 показан только процедурный объект, соответствующий good-enough?.
После того, как были определены внутренние процедуры, мы вычислили выражение
(sqrt-iter 1.0), по-прежнему в окружении E1. То есть, процедурный объект, связанный в E1 с именем sqrt-iter, был вызван с аргументом 1. При этом появилось
окружение E2, в котором guess, параметр sqrt-iter, связан со значением 1. В свою
очередь, sqrt-iter вызвала good-enough? со значением guess (из E2) в качестве
аргумента. Получилось еще одно окружение, E3, в котором guess (параметр goodenough?) связан со значением 1. Несмотря на то, что и sqrt-iter, и good-enough?
имеют по параметру с одинаковым именем guess, это две различные переменные, расположенные в разных кадрах. Кроме того, и E2, и E3 в качестве объемлющего окружения
имеют E1, поскольку как sqrt-iter, так и good-enough? в качестве окружения содержат указатель на E1. Одним из следствий этого является то, что символ x в теле
good-enough? обозначает связывание x, в окружении E1, а точнее, то значение x, с
которым была вызвана исходная процедура sqrt.
Таким образом, модель вычислений с окружениями объясняет две ключевых особенности, которые делают внутренние определения процедур полезным способом модуляризации программ:
• Имена внутренних процедур не путаются с именами, внешними по отношению к
охватывающей процедуре, поскольку локальные имена процедур будут связываться в
кадре, который процедура создает при своем запуске, а не в глобальном окружении.
• Внутренние процедуры могут обращаться к аргументам охватывающих процедур,
просто используя имена параметров как свободные переменные. Это происходит потому,
что тело внутренней процедуры выполняется в окружении, подчиненном окружению, где
вычисляется объемлющая процедура.
Упражнение 3.11.
В разделе 3.2.3 мы видели, как модель с окружениями описывает поведение процедур, обладающих внутренним состоянием. Теперь мы рассмотрели, как работают локальные определения.
Типичная процедура с передачей сообщений пользуется и тем, и другим. Рассмотрим процедуру
моделирования банковского счета из раздела 3.1.1:
(define (make-account balance)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Недостаточно денег на счете"))
(define (deposit amount)
(set! balance (+ balance amount))
balance)
(define (dispatch m)
(cond ((eq? m ’withdraw) withdraw)
((eq? m ’deposit) deposit)
(else (error "Неизвестный вызов -- MAKE-ACCOUNT"
m))))
dispatch)

3.3. Моделирование при помощи изменяемых данных

241

Покажите, какая структура окружений создается последовательностью действий
(define acc (make-account 50))
((acc ’deposit) 40)
90
((acc ’withdraw) 60)
30
Где хранится внутреннее состояние acc? Предположим, что мы определяем еще один счет
(define acc2 (make-account 100))
Каким образом удается не смешивать внутренние состояния двух счетов? Какие части структуры
окружений общие у acc и acc2?

3.3. Моделирование при помощи изменяемых данных
В главе 2 составные данные использовались как средство построения вычислительных объектов, состоящих из нескольких частей, с целью моделирования объектов реального мира, обладающих несколькими свойствами. В этой главе мы ввели дисциплину абстракции данных, согласно которой структуры данных описываются в терминах
конструкторов, которые создают объекты данных, и селекторов, которые обеспечивают
доступ к частям составных объектов. Однако теперь мы знаем, что есть еще один аспект
работы с данными, который остался незатронутым в главе 2. Желание моделировать
системы, которые состоят из объектов, обладающих изменяющимся состоянием, вызывает потребность не только создавать составные объекты данных и иметь доступ к их
частям, но и изменять их. Чтобы моделировать объекты с изменяющимся состоянием,
мы будем проектировать абстракции данных, которые, помимо конструкторов и селекторов, включают мутаторы (mutators), модифицирующие объекты данных. Например,
моделирование банковской системы требует от нас способности изменять балансы счетов. Таким образом, структура данных, изображающая банковский счет, может обладать
операцией
(set-balance! hсчетi hновое-значениеi)

которая присваивает балансу указанного счета указанное значение. Объекты данных, для которых определены мутаторы, называются изменяемыми объектами данных
(mutable data objects).
В главе 2 в качестве универсального «клея» для построения составных данных мы
ввели пары. Этот раздел мы начинаем с определения мутаторов для пар, так, чтобы пары
могли служить строительным материалом для построения изменяемых объектов данных.
Мутаторы значительно увеличивают выразительную силу пар и позволяют нам строить
структуры данных помимо последовательностей и деревьев, с которыми мы имели дело
в разделе 2.2. Кроме того, мы строим несколько примеров моделей, где сложные системы
представляются в виде множества объектов, обладающих внутренним состоянием.

Глава 3. Модульность, объекты и состояние

242
x

c

d

a

b

e

f

y

Рис. 3.12. Списки x: ((a b) c d) и y: (e f).

3.3.1. Изменяемая списковая структура
Базовые операции над парами — cons, car и cdr — можно использовать для построения списковой структуры и для извлечения частей списковой структуры, однако
изменять списковую структуру они не позволяют. То же верно и для операций со списками, которые мы до сих пор использовали, таких, как append и list, поскольку эти
последние можно определить в терминах cons, car и cdr. Для модификации списковых
структур нам нужны новые операции.
Элементарные мутаторы для пар называются setcar! и set-cdr!. Setcar! принимает два аргумента, первый из которых обязан быть парой. Он модифицирует эту
пару, подставляя вместо указателя car указатель на свой второй аргумент16 .
В качестве примера предположим, что переменная x имеет значением список ((a
b) c d), а переменная y список (e f), как показано на рисунке 3.12. Вычисление
выражения (set-car! x y) изменяет пару, с которой связана переменная x, заменяя
ее car на значение y. Результат этой операции показан на рисунке 3.13. Структура
x изменилась, и теперь ее можно записать как ((e f) c d). Пары представляющие
список (a b), на которые указывал замененный указатель, теперь отделены от исходной
структуры17 .
Сравните рисунок 3.13 с рис. 3.14, на котором представлен результат выполнения
(define z (cons y (cdr x))), где x и y имеют исходные значения с рис. 3.12.
Здесь переменная z оказывается связана с новой парой, созданной операцией cons;
список, который является значением x, не меняется.
Операция set-cdr! подобна set-car!. Единственная разница состоит в том, что
заменяется не указатель car, а указатель cdr. Результат применения (set-cdr! x y)
16 Значения, которые возвращают set-car! и set-cdr!, зависят от реализации. Подобно set!, эти операции должны использоваться исключительно ради своего побочного эффекта.
17 Здесь мы видим, как операции изменения данных могут создавать «мусор», который не является частью
никакой доступной структуры. В разделе 5.3.2 мы увидим, что системы управления памятью Лиспа включают сборщик мусора (garbage collector), который находит и освобождает память, используемую ненужными
парами.

3.3. Моделирование при помощи изменяемых данных

243

x
c

d

a

b

e

f

y

Рис. 3.13. Результат применения (set-car! x y) к спискам, изображенным на
рис. 3.12.

x
c

d

a

b

e

f

z

y

Рис. 3.14. Результат применения (define z (cons y (cdr x)) к спискам, показанным на рис. 3.12.

Глава 3. Модульность, объекты и состояние

244
x

c

d

a

b

e

f

y

Рис. 3.15. Результат применения (set-cdr! x y) к спискам с рис. 3.12.

к спискам, изображенным на рис. 3.12, показан на рис. 3.15. Здесь указатель cdr в
составе x заменился указателем на (e f). Кроме того, список (c d), который был
cdr-ом x, оказывается отделенным от структуры.
Cons создает новую списковую структуру, порождая новые пары, а setcar! и setcdr! изменяют существующие. В сущности, мы могли бы реализовать cons при помощи
этих двух мутаторов и процедуры get-new-pair, которая возвращает новую пару,
не являющуюся частью никакой существующей списковой структуры. Мы порождаем
новую пару, присваиваем ее указателям car и cdr нужные значения, и возвращаем
новую пару в качестве результата cons18 :
(define (cons x y)
(let ((new (get-new-pair)))
(set-car! new x)
(set-cdr! new y)
new))

Упражнение 3.12.
В разделе 2.2.1 была введена следующая процедура для добавления одного списка к другому:
(define (append x y)
(if (null? x)
y
(cons (car x) (append (cdr x) y))))
Append порождает новый список, по очереди наращивая элементы x в начало y. Процедура
append! подобна append, но только она является не конструктором, а мутатором. Она склеивает списки вместе, изменяя последнюю пару x так, что ее cdr становится равным y. (Вызов
append! с пустым x является ошибкой.)
18 Get-new-pair — одна из операций, которые требуется предоставить как часть системы управления
памятью в рамках реализации Лиспа. Мы рассмотрим эти вопросы в разделе 5.3.1.

3.3. Моделирование при помощи изменяемых данных

245

(define (append! x y)
(set-cdr! (last-pair x) y)
x)
Здесь last-pair — процедура, которая возвращает последнюю пару своего аргумента:
(define (last-pair x)
(if (null? (cdr x))
x
(last-pair (cdr x))))
Рассмотрим последовательность действий
(define x (list ’a ’b))
(define y (list ’c ’d))
(define z (append

x y))

z
(a b c d)
(cdr x)
hответi
(define w (append! x y))
w
(a b c d)
(cdr x)
hответi
Каковы будут пропущенные hответыi? Объясните, нарисовав стрелочные диаграммы.
Упражнение 3.13.
Рассмотрим следующую процедуру make-cycle, которая пользуется last-pair из упражнения 3.12:
(define (make-cycle x)
(set-cdr! (last-pair x) x)
x)
Нарисуйте стрелочную диаграмму, которая изображает структуру z, созданную таким кодом:
(define z (make-cycle (list ’a ’b ’c)))
Что случится, если мы попробуем вычислить (last-pair z)?
Упражнение 3.14.
Следующая процедура, хотя и сложна для понимания, вполне может оказаться полезной:
(define (mystery x)
(define (loop x y)

Глава 3. Модульность, объекты и состояние

246
z1
x

a

b

Рис. 3.16. Список z1, порождаемый выражением (cons x x).

z1
x

a

b

Рис. 3.17. Список z2, порождаемый выражением (cons (list ’a ’b) (list ’a
’b)).

(if (null? x)
y
(let ((temp (cdr x)))
(set-cdr! x y)
(loop temp x))))
(loop x ’()))
Loop пользуется «временной» переменной temp, чтобы сохранить старое значение cdr пары x,
поскольку set-cdr! на следующей строке его разрушает. Объясните, что за задачу выполняет
mystery. Предположим, что переменная v определена выражением (define v (list ’a ’b
’c ’d). Нарисуйте диаграмму, которая изображает список, являющийся значением v. Допустим,
что теперь мы выполняем (define w (mystery v)). Нарисуйте стрелочные диаграммы, которые показывают структуры v и w после вычисления этого выражения. Что будет напечатано в
качестве значений v и w?

Разделение данных и их идентичность
В разделе 3.1.3 мы упоминали теоретические вопросы «идентичности» и «изменения»,
которые возникают с появлением присваивания. Эти вопросы начинают иметь практическое значение тогда, когда отдельные пары разделяются (are shared) между различными
объектами данных. Рассмотрим, например, структуру, которая создается таким кодом:
(define x (list ’a ’b))
(define z1 (cons x x))

3.3. Моделирование при помощи изменяемых данных

247

Как показано на рис. 3.16, z1 представляет собой пару, в которой car и cdr указывают
на одну и ту же пару x. Разделение x между car и cdr пары z1 возникает оттого, что
cons реализован простейшим способом. В общем случае построение списков с помощью
cons приводит к возникновению сложносвязанной сети пар, в которой многие пары
разделяются между многими различными структурами.
В противоположность рис. 3.16, рис. 3.17 показывает структуру, которая порождается
кодом
(define z2 (cons (list ’a ’b) (list ’a ’b)))

В этой структуре пары двух списков (a b) различны, притом, что сами символы разделяются19 .
Если мы рассматриваем z1 и z2 как списки, они представляют «один и тот же»
список ((a b) a b). Вообще говоря, разделение данных невозможно заметить, если
мы работаем со списками только при помощи операций cons, car и cdr. Однако если
мы вводим мутаторы, работающие со списковой структурой, разделение данных начинает
иметь значение. Как пример случая, когда разделение влияет на результат, рассмотрим
следующую процедуру, которая изменяет car структуры, к которой она применяется:
(define (set-to-wow! x)
(set-car! (car x) ’wow)
x)

Несмотря на то, что z1 и z2 имеют «одинаковую» структуру, применение к ним процедуры set-to-wow! дает различные результаты. В случае с z1 изменение car влияет и
на cdr, поскольку здесь car и cdr — это одна и та же пара. В случае с z2, car и cdr
различны, так что set-to-wow! изменяет только car:
z1
((a b) a b)
(set-to-wow! z1)
((wow b) wow b)
z2
((a b) a b)
(set-to-wow! z2)
((wow b) a b)

Один из способов распознать разделение данных в списковых структурах — это воспользоваться предикатом eq?, который мы ввели в разделе 2.3.1 как метод проверки
двух символов на равенство. В более общем случае (eq? x y) проверяет, являются
ли x и y одним объектом (то есть, равны ли x и y друг другу как указатели). Так что,
19 Пары различаются потому, что каждый вызов cons порождает новую пару. Символы разделяются; в
Scheme существует только один символ для каждого данного имени. Поскольку Scheme не дает возможности
изменять символ, это разделение невозможно заметить. Заметим, кроме того, что именно разделение позволяет
нам сравнивать символы при помощи eq?, который просто проверяет равенство указателей.

248

Глава 3. Модульность, объекты и состояние

если z1 и z2 определены как на рисунках 3.16 и 3.17, (eq? (car z1) (cdr z1))
будет истинно, а (eq? (car z2) (cdr z2)) ложно.
Как будет видно в последующих разделах, с помощью разделения данных мы значительно расширим репертуар структур данных, которые могут быть представлены через
пары. С другой стороны, разделение сопряжено с риском, поскольку изменения в одних структурах могут затрагивать и другие структуры, разделяющие те части, которые
подвергаются изменению. Операции изменения set-car! и set-cdr! нужно использовать осторожно; если у нас нет точного понимания, какие из наших объектов разделяют
данные, изменение может привести к неожиданным результатам20 .
Упражнение 3.15.
Нарисуйте стрелочные диаграммы, объясняющие, как set-to-wow! действует на структуры z1
и z2 из этого раздела.
Упражнение 3.16.
Бен Битобор решил написать процедуру для подсчета числа пар в любой списковой структуре.
«Это легко, — думает он. — Число пар в любой структуре есть число пар в car плюс число пар в
cdr плюс один на текущую пару». И он пишет следующую процедуру:
(define (count-pairs x)
(if (not (pair? x))
0
(+ (count-pairs (car x))
(count-pairs (cdr x))
1)))
Покажите, что эта процедура ошибочна. В частности, нарисуйте диаграммы, представляющие
списковые структуры ровно из трех пар, для которых Бенова процедура вернет 3; вернет 4; вернет
7; вообще никогда не завершится.
Упражнение 3.17.
Напишите правильную версию процедуры count-pairs из упражнения 3.16, которая возвращает
число различных пар в любой структуре. (Подсказка: просматривайте структуру, поддерживая при
этом вспомогательную структуру, следящую за тем, какие пары уже были посчитаны.)
Упражнение 3.18.
Напишите процедуру, которая рассматривает список и определяет, содержится ли в нем цикл, то
есть, не войдет ли программа, которая попытается добраться до конца списка, продвигаясь по
полям cdr, в бесконечный цикл. Такие списки порождались в упражнении 3.13.
20 Тонкости работы с разделением изменяемых данных отражают сложности с понятием «идентичности» и
«изменения», о которых мы говорили в разделе 3.1.3. Там мы отметили, что введение в наш язык понятия изменения требует, чтобы у составного объекта была «индивидуальность», которая представляет собой
нечто отличное от частей, из которых он состоит. В Лиспе мы считаем, что именно эта «индивидуальность»
проверяется предикатом eq?, то есть сравнением указателей. Поскольку в большинстве реализаций Лиспа
указатель — это, в сущности, адрес в памяти, мы «решаем проблему» определения индивидуальности объектов, постановив, что «сам» объект данных есть информация, хранимая в некотором наборе ячеек памяти
компьютера. Для простых лисповских программ этого достаточно, но такой метод не способен разрешить
общий вопрос «идентичности» в вычислительных моделях.

3.3. Моделирование при помощи изменяемых данных

249

Упражнение 3.19.
Переделайте упражнение 3.18, используя фиксированное количество памяти. (Тут нужна достаточно хитрая идея.)

Изменение как присваивание
Когда мы вводили понятие составных данных, в разделе 2.1.3 мы заметили, что пары
можно представить при помощи одних только процедур:
(define (cons x y)
(define (dispatch m)
(cond ((eq? m ’car) x)
((eq? m ’cdr) y)
(else (error "Неопределенная операция -- CONS" m))))
dispatch)
(define (car z) (z ’car))
(define (cdr z) (z ’cdr))

То же наблюдение верно и для изменяемых данных. Изменяемые объекты данных можно реализовать при помощи процедур и внутреннего состояния. Например, можно расширить приведенную реализацию пар, так, чтобы set-car! и set-cdr! обрабатывались по аналогии с реализацией банковских счетов через make-account из раздела 3.1.1:
(define (cons x y)
(define (set-x! v) (set! x v))
(define (set-y! v) (set! y v))
(define (dispatch m)
(cond ((eq? m ’car) x)
((eq? m ’cdr) y)
((eq? m ’set-car!) set-x!)
((eq? m ’set-cdr!) set-y!)
(else (error "Неопределенная операция -- CONS" m))))
dispatch)
(define (car z) (z ’car))
(define (cdr z) (z ’cdr))
(define (set-car! z new-value)
((z ’set-car!) new-value)
z)
(define (set-cdr! z new-value)
((z ’set-cdr!) new-value)
z)

Глава 3. Модульность, объекты и состояние

250

Операция
(define q (make-queue)
(insert-queue! q ’a)
(insert-queue! q ’b)
(delete-queue! q)
(insert-queue! q ’c)
(insert-queue! q ’d)
(delete-queue! q)

Результат
a
a
b
b
b
c

b
c
c d
d

Рис. 3.18. Операции над очередью.

Теоретически, чтобы описать поведение изменяемых данных, не требуется ничего,
кроме присваивания. Как только мы вводим в наш язык set!, мы сталкиваемся со всеми
проблемами, не только собственно присваивания, но и вообще изменяемых данных21 .
Упражнение 3.20.
Нарисуйте диаграммы окружений, изображающие выполнение последовательности выражений
(define x (cons 1 2))
(define z (cons x x))
(set-car! (cdr z) 17)
(car x)
17
с помощью вышеприведенной процедурной реализации пар. (Ср. с упражнением 3.11.)

3.3.2. Представление очередей
Мутаторы set-car! и set-cdr! позволяют нам строить из пар такие структуры,
какие мы не смогли бы создать только при помощи cons, car и cdr. В этом разделе будет показано, как представить структуру данных, которая называется очередь. В
разделе 3.3.3 мы увидим, как реализовать структуру, называемую таблицей.
Очередь (queue) представляет собой последовательность, в которую можно добавлять
элементы с одного конца (он называется хвостом (rear)) и убирать с другого (он называется головой (front)). На рисунке 3.18 изображено, как в изначально пустую очередь
добавляются элементы a и b. Затем a убирается из очереди, в нее добавляются c и d,
потом удаляется b. Поскольку элементы удаляются всегда в том же порядке, в котором
они были добавлены, иногда очередь называют буфером FIFO (англ. first in, first out —
первым вошел, первым вышел).
С точки зрения абстракции данных, можно считать, что очередь определяется следующим набором операций:
21 С другой стороны, с точки зрения реализации, присваивание требует модификации окружения, которое
само по себе является изменяемой структурой данных. Таким образом, присваивание и изменяемость данных
обладают равной мощностью: каждое из них можно реализовать при помощи другого.

3.3. Моделирование при помощи изменяемых данных

251

• конструктор (make-queue) возвращает пустую очередь (очередь, в которой нет
ни одного элемента).
• два селектора: (empty-queue? hочередьi) проверяет, пуста ли очередь,
(front-queue hочередьi) возвращает объект, находящийся в голове очереди. Если
очередь пуста, он сообщает об ошибке. Очередь не модифицируется.
• Два мутатора: (insert-queue! hочередьi hэлементi) вставляет элемент в
хвост очереди и возвращает в качестве значения измененную очередь; (delete-queue!
hочередьi) удаляет элемент в голове очереди и возвращает в качестве значения измененную очередь. Если перед уничтожением элемента очередь оказывается пустой, выводится сообщение об ошибке.
Поскольку очередь есть последовательность элементов, ее, разумеется, можно было
бы представить как обыкновенный список; головой очереди был бы car этого списка, вставка элемента в очередь сводилась бы к добавлению нового элемента в конец
списка, а уничтожение элемента из очереди состояло бы просто во взятии cdr списка.
Однако такая реализация неэффективна, поскольку для вставки элемента нам пришлось
бы просматривать весь список до конца. Поскольку единственный доступный нам метод просмотра списка — это последовательное применение cdr, такой просмотр требует
Θ(n) шагов для очереди с n членами. Простое видоизменение спискового представления
преодолевает этот недостаток, позволяя нам реализовать операции с очередью так, чтобы все они требовали Θ(1) шагов; то есть, чтобы число шагов алгоритма не зависело от
длины очереди.
Сложность со списковым представлением возникает из-за необходимости искать конец списка. Искать приходится потому, что, хотя стандартный способ представления
списка в виде цепочки пар дает нам указатель на начало списка, легкодоступного указателя на конец он не дает. Модификация, обходящая этот недостаток, состоит в том,
чтобы представлять очередь в виде списка, и держать еще дополнительный указатель
на его последнюю пару. В таком случае, когда требуется вставить элемент, мы можем
просто посмотреть на этот указатель и избежать за счет этого просмотра всего списка.
Очередь, таким образом, представляется в виде пары указателей, frontptr и rearptr, которые обозначают, соответственно, первую и последнюю пару обыкновенного
списка. Поскольку нам хочется, чтобы очередь была объектом с собственной индивидуальностью, соединить эти два указателя можно с помощью cons, так что собственно очередь будет результатом cons двух указателей. Такое представление показано на
рис. 3.19.
Во время определения операций над очередью мы пользуемся следующими процедурами, которые позволяют нам читать и записывать указатели на начало и конец очереди:
(define (front-ptr queue) (car queue))
(define (rear-ptr queue) (cdr queue))
(define (set-front-ptr! queue item) (set-car! queue item))
(define (set-rear-ptr! queue item) (set-cdr! queue item))

Теперь можно реализовать сами операции над очередью. Очередь будет считаться

Глава 3. Модульность, объекты и состояние

252
q
front-ptr

a

rear-ptr

b

c

Рис. 3.19. Реализация очереди в виде списка с указателями на начало и конец.

пустой, если ее головной указатель указывает на пустой список:
(define (empty-queue? queue) (null? (front-ptr queue)))

Конструктор make-queue возвращает в качестве исходно пустой очереди пару, в которой и car, и cdr являются пустыми списками:
(define (make-queue) (cons ’() ’()))

При обращении к элементу в голове очереди мы возвращаем car пары, на которую
указывает головной указатель:
(define (front-queue queue)
(if (empty-queue? queue)
(error "FRONT вызвана с пустой очередью" queue)
(car (front-ptr queue))))

Чтобы вставить элемент в конец очереди, мы используем метод, результат которого показан на рисунке 3.20. Первым делом мы создаем новую пару, car которой содержит
вставляемый элемент, а cdr — пустой список. Если очередь была пуста, мы перенаправляем на эту пару и головной, и хвостовой указатели. В противном случае, мы изменяем
последнюю пару очереди так, чтобы следующей была новая пара, и хвостовой указатель
тоже перенаправляем на нее же.
(define (insert-queue! queue item)
(let ((new-pair (cons item ’())))
(cond ((empty-queue? queue)
(set-front-ptr! queue new-pair)
(set-rear-ptr! queue new-pair)
queue)
(else
(set-cdr! (rear-ptr queue) new-pair)
(set-rear-ptr! queue new-pair)
queue))))

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

3.3. Моделирование при помощи изменяемых данных

253

q
rear-ptr

front-ptr

a

b

c

d

Рис. 3.20. Результат применения (insert-queue! q ’d) к очереди с рисунка 3.19

q
rear-ptr

front-ptr

a

b

c

d

Рис. 3.21. Результат применения (delete-queue! q) к очереди с рис. 3.20.

(см. рис. 3.21)22 :
(define (delete-queue! queue)
(cond ((empty-queue? queue)
(error "DELETE! вызвана с пустой очередью" queue))
(else
(set-front-ptr! queue (cdr (front-ptr queue)))
queue)))

Упражнение 3.21.
Бен Битобор решает протестировать вышеописанную реализацию. Он вводит процедуры в интерпретаторе Лиспа и тестирует их:
(define q1 (make-queue))
(insert-queue! q1 ’a)
((a) a)
(insert-queue! q1 ’b)
22 В случае, если первый элемент — одновременно и последний, после его уничтожения головной указатель
окажется пустым списком, и это будет означать, что очередь пуста; нам незачем заботиться о хвостовом указателе, который по-прежнему будет указывать на уничтоженный элемент, поскольку empty-queue? смотрит
только на голову.

Глава 3. Модульность, объекты и состояние

254
((a b) b)
(delete-queue! q1)
((b) b)
(delete-queue! q1)
(() b)

«Ничего не работает! — жалуется он. — Ответ интерпретатора показывает, что последний элемент
попадает в очередь два раза. А когда я оба элемента уничтожаю, второе b по-прежнему там сидит,
так что очередь не становится пустой, хотя должна бы». Ева Лу Атор говорит, что Бен просто
не понимает, что происходит. «Дело не в том, что элементы два раза оказываются в очереди, —
объясняет она. — Дело в том, что стандартная лисповская печаталка не знает, как устроено
представление очереди. Если ты хочешь, чтобы очередь правильно печаталась, придется написать
специальную процедуру распечатки очередей». Объясните, что имеет в виду Ева Лу. В частности,
объясните, почему в примерах Бена на печать выдается именно такой результат. Определите
процедуру print-queue, которая берет на входе очередь и выводит на печать последовательность
ее элементов.
Упражнение 3.22.
Вместо того, чтобы представлять очередь как пару указателей, можно построить ее в виде процедуры с внутренним состоянием. Это состояние будет включать указатели на начало и конец
обыкновенного списка. Таким образом, make-queue будет иметь вид
(define (make-queue)
(let ((front-ptr ...)
(rear-ptr ...))
hопределения внутренних процедурi
(define (dispatch m) ...)
dispatch))
Закончите определение make-queue и реализуйте операции над очередями с помощью этого
представления.
Упражнение 3.23.
Дек (deque, double-ended queue, «двусторонняя очередь») представляет собой последовательность,
элементы в которой могут добавляться и уничтожаться как с головы, так и с хвоста. На деках определены такие операции: конструктор make-deque, предикат empty-deque?, селекторы front-deque и rear-deque, и мутаторы frontinsertdeque!, rear-insert-deque!,
front-delete-deque! и rear-delete-deque!. Покажите, как представить дек при помощи
пар, и напишите реализацию операций23 .Все операции должны выполняться за Θ(1) шагов.

3.3.3. Представление таблиц
Когда в главе 2 мы изучали различные способы представления множеств, то в разделе 2.3.3 была упомянута задача поддержания таблицы с идентифицирующими ключами.
При реализации программирования, управляемого данными, в разделе 2.4.3, активно
23 Осторожно,

не заставьте ненароком интерпретатор печатать циклическую структуру (см. упр. 3.13).

3.3. Моделирование при помощи изменяемых данных

255

table

*table*
a

1

b

2

c

3

Рис. 3.22. Таблица, представленная в виде списка с заголовком.

использовались двумерные таблицы, в которых информация заносится и ищется с использованием двух ключей. Теперь мы увидим, как такие таблицы можно строить при
помощи изменяемых списковых структур.
Сначала рассмотрим одномерную таблицу, где каждый элемент хранится под отдельным ключом. Ее мы реализуем как список записей, каждая из которых представляет
собой пару, состоящую из ключа и связанного с ним значения. Пары связаны вместе в
список при помощи цепочки пар, в каждой из которых car указывают на одну из записей. Эти связующие пары называются хребтом (backbone) таблицы. Для того, чтобы у
нас было место, которое мы будем изменять при добавлении новой записи, таблицу мы
строим как список с заголовком (headed list). У такого списка есть в начале специальная
хребтовая пара, в которой хранится фиктивная «запись» — в данном случае произвольно
выбранный символ *table*. На рисунке 3.22 изображена стрелочная диаграмма для
таблицы
a: 1
b: 2
c: 3

Информацию из таблицы можно извлекать при помощи процедуры lookup, которая получает ключ в качестве аргумента, а возвращает связанное с ним значение (либо
ложь, если в таблице с этим ключом никакого значения не связано). Lookup определена при помощи операции assoc, которая требует в виде аргументов ключ и список
записей. Обратите внимание, что assoc не видит фиктивной записи. Assoc возвращает
запись, которая содержит в car искомый ключ24 . Затем lookup проверяет, что запись,
возвращенная assoc, не есть ложь, и возвращает значение (то есть cdr) записи.
(define (lookup key table)
(let ((record (assoc key (cdr table))))
(if record
(cdr record)
false)))
24 Поскольку assoc пользуется equal?, в качестве ключей она может распознавать символы, числа и
списковые структуры.

256

Глава 3. Модульность, объекты и состояние

(define (assoc key records)
(cond ((null? records) false)
((equal? key (caar records)) (car records))
(else (assoc key (cdr records)))))

Чтобы вставить в таблицу значение под данным ключом, сначала мы с помощью
assoc проверяем, нет ли уже в таблице записи с этим ключом. Если нет, мы формируем новую запись, «сconsивая» ключ со значением, и вставляем ее в начало списка
записей таблицы, после фиктивной записи. Если же в таблице уже была запись с этим
ключом, мы переставляем cdr записи на указанное новое значение. Заголовок таблицы используется как неподвижное место, которое мы можем изменять при порождении
новой записи25 .
(define (insert! key value table)
(let ((record (assoc key (cdr table))))
(if record
(set-cdr! record value)
(set-cdr! table
(cons (cons key value) (cdr table)))))
’ok)

Для того, чтобы создать таблицу, мы просто порождаем список, содержащий символ
*table*:
(define (make-table)
(list ’*table*))

Двумерные таблицы
В двумерной таблице каждое значение индексируется двумя ключами. Такую таблицу мы можем построить как одномерную таблицу, в которой каждый ключ определяет
подтаблицу. На рисунке 3.23 изображена стрелочная диаграмма для таблицы
math:
+: 43
-: 45
*: 42
letters:
a: 97
b: 98

содержащей две подтаблицы (подтаблицам не требуется специального заголовочного
символа, поскольку для этой цели служит ключ, идентифицирующий подтаблицу).
Когда мы ищем в таблице элемент, сначала при помощи первого ключа мы находим
нужную подтаблицу. Затем при помощи второго ключа мы определяем запись внутри
подтаблицы.
25 Таким образом, первая хребтовая пара является объектом, который представляет «саму» таблицу; то есть,
указатель на таблицу — это указатель на эту пару. Таблица всегда начинается с одной и той же хребтовой
пары. Будь это устроено иначе, пришлось бы возвращать из insert! новое начало таблицы в том случае,
когда создается новая запись.

3.3. Моделирование при помощи изменяемых данных

257

table

*table*
letters

a

97

-

45

a

98

math

+

43

Рис. 3.23. Двумерная таблица.

*

42

258

Глава 3. Модульность, объекты и состояние

(define (lookup key-1 key-2 table)
(let ((subtable (assoc key-1 (cdr table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))
(if record
(cdr record)
false))
false)))

Чтобы вставить в таблицу новый элемент под двумя ключами, мы при помощи assoc
проверяем, соответствует ли какая-нибудь подтаблица первому ключу. Если нет, строим
новую подтаблицу, содержащую единственную запись (key-2, value), и заносим ее в
таблицу под первым ключом. Если для первого ключа уже существует подтаблица, мы
вставляем новую запись в эту подтаблицу, используя вышеописанный метод вставки для
одномерных таблиц:
(define (insert! key-1 key-2 value table)
(let ((subtable (assoc key-1 (cdr table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))
(if record
(set-cdr! record value)
(set-cdr! subtable
(cons (cons key-2 value)
(cdr subtable)))))
(set-cdr! table
(cons (list key-1
(cons key-2 value))
(cdr table)))))
’ok)

Создание локальных таблиц
Операции lookup и insert!, которые мы определили, принимают таблицу в качестве аргумента. Это позволяет писать программы, которые обращаются более, чем к
одной таблице. Другой способ работы с множественными таблицами заключается в том,
чтобы иметь для каждой из них свои отдельные процедуры lookup и insert!. Мы
можем этого добиться, представив таблицу в процедурном виде, как объект, который
поддерживает внутреннюю таблицу как часть своего локального состояния. Когда ему
посылают соответствующее сообщение, этот «табличный объект» выдает процедуру, с
помощью которой можно работать с его внутренним состоянием. Вот генератор двумерных таблиц, представленных таким способом:
(define (make-table)
(let ((local-table (list ’*table*)))
(define (lookup key-1 key-2)
(let ((subtable (assoc key-1 (cdr local-table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))

3.3. Моделирование при помощи изменяемых данных

259

(if record
(cdr record)
false))
false)))
(define (insert! key-1 key-2 value)
(let ((subtable (assoc key-1 (cdr local-table))))
(if subtable
(let ((record (assoc key-2 (cdr subtable))))
(if record
(set-cdr! record value)
(set-cdr! subtable
(cons (cons key-2 value)
(cdr subtable)))))
(set-cdr! local-table
(cons (list key-1
(cons key-2 value))
(cdr local-table)))))
’ok)
(define (dispatch m)
(cond ((eq? m ’lookup-proc) lookup)
((eq? m ’insert-proc!) insert!)
(else (error "Неизвестная операция -- TABLE" m))))
dispatch))

Make-table позволяет нам реализовать операции get и put из раздела 2.4.3, так:
(define operation-table (make-table))
(define get (operation-table ’lookup-proc))
(define put (operation-table ’insert-proc!))

Get в качестве аргументов берет два ключа, а put два ключа и значение. Обе
операции обращаются к одной и той же локальной таблице, которая инкапсулируется в
объекте, созданном посредством вызова make-table.
Упражнение 3.24.
В реализациях таблиц в этом разделе ключи всегда проверяются на равенство с помощью equal?
(который, в свою очередь, зовется из assoc). Это не всегда то, что нужно. Например, можно
представить себе таблицу с числовыми ключами, где не требуется точного совпадения с числом,
которое мы ищем, а нужно только совпадение с определенной допустимой ошибкой. Постройте
конструктор таблиц make-table, который в качестве аргумента принимает процедуру samekey? для проверки равенства ключей. Make-table должна в