Функциональное Программирование 5030018700

В книге английских специалистов рассмотрены проблемы аппликативного программирования, существенно повышающего интеллекту

155 14 20MB

Russian Pages 638 [639] Year 1993

Report DMCA / Copyright

DOWNLOAD PDF FILE

Recommend Papers

Функциональное Программирование
 5030018700

  • 0 0 0
  • Like this paper and download? You can publish your own PDF file online for free in a few minutes! Sign Up
File loading please wait...
Citation preview

А . Филд, П. Харрисон ФУНКЦИОНАЛЬНОЕ ПРОГРАМ М ИРОВАНИЕ Перевод с английского М. В. Горбатовой, А. А. Рябинина, канд. техн. наук В. Л. Торхова, М. В. Федорова под редакцией акад. АЕН РСФСР В. А. Горбатова

•Москва «Мир» 1993

Functional Programming Anthony J. Field Peter G. Harrison Im p eria l C ollege of S cience and T echnology U n iv e rsity of L ondon

A d dison-W esley P u b lis h in g C om pany W okingham , E n g la n d • R ead in g , M a s sa c h u se tts • M enlo P a rk , C a lifo rn ia New Y o rk -D o n M ills, O n ta rio - A m sterd am - Bonn S y d n ey • S in g a p o re • T okyo • M ad rid • S a n Ju a n

ББК 22.18 Ф 51 УДК 519.682

Ф51

Филд А., Харрисон П. Функциональное программирование: Пер. с англ. — М.: Мир, 1993. — 637 с., ил. ISBN 5-03-001870-0 В книге английских специалистов рассмотрены проблемы аппликативного программирования, существенно повышающего интеллектуальность разраба­ тываемых программ по сравнению с традиционным программированием. При этом спецификация предметной области существенно упрощает труд програм­ миста. Особое внимание уделяется вопросам реализации функциональных языков, основанной на Х-исчислении Черча. В качестве базового языка рас­ сматривается функциональный язык Норе, имеющий простой и ясный синтак­ сис. Изложение сопровождается многочисленными примерами конкретных программ. Для программистов как начинающих, так и профессионалов, а также спе­ циалистов в области информатики.

2404010000-060 125-91 041 (01)-93

ББК 22.18

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

Редакция литературы по информатике

ISBN 5-03-001870-0 (русск.) ISBN 0-201-19249-7 (англ.)

© 1988 Addison-Wesley Publishing Comрапу, Inc. © перевод на русский язык, Горбатова М. В., Рябинин А. А., Торхов В. Л., Федоров М. В., 1993.

ПРЕДИСЛОВИЕ РЕДАКТОРА ПЕРЕВОДА Одно из значительных мест в исследованиях по теоретиче­ скому программированию занимает функциональное програм­ мирование. Ведущиеся в течение уже трех десятилетий разра­ ботки в этой области в последнее время имеют устойчивую тен­ денцию к расширению. Выполнение программы на функциональном языке, говоря неформально, заключается в вызове функции, аргументами кото­ рой являются значения других функций, которые в свою оче­ редь также могут быть суперпозициями в общем случае произ­ вольной глубины. С точки зрения программиста, основная часть программы состоит из совокупности определений функций, как правило, рекурсивных. Такая особенность функциональных язы­ ков обусловливает ряд достоинств, к основным из которых, на наш взгляд, относятся следующие. Прежде всего это присущая определению функций декларативность, позволяющая писать программы в терминах того, что надо делать, а не как это делается. Далее, бесспорным преимуществом этих языков, су­ щественно повышающим надежность программирования, яв­ ляется свойство прозрачности по ссылкам (referential transpa­ rency)— то, что и понимается в рамках данной книги под функ­ циональностью языка. И наконец, имеющаяся возможность формального преобразования функциональных программ с целью их оптимизации. Все это делает функциональное программиро­ вание весьма привлекательным как в теоретическом, так и в практическом аспектах. Говоря о существующей практике применения функциональ­ ных языков, необходимо в первую очередь указать такую об­ ласть, как интеллектуальные автоматизированные системы. Здесь функциональные языки прошли наиболее полную апро­ бацию и признаны вполне адекватным инструментом. Предлагаемая монография представляется полезной для на­ учных работников и специалистов в области программирования, создания интеллектуальных автоматизированных систем, а так­ же для аспирантов и студентов старших курсов соответствую­ щих специальностей. Перевод книги выполнен М. В. Горбатовой (гл. 1—4), А. А. Рябининым (гл. 5—14, приложения, предметный указа­ тель), канд. техн. наук В. Л. Торховым (предисловие, гл. 17, 18), М. В. Федоровым (гл. 15, 16, 19, 20, ответы к упражне­ ниям). В. А. Горбатов

Посвящается Cape

ПРЕДИСЛОВИЕ

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

Предисловие

7

тельной технике, а также профессионалов, желающих познако­ миться с современным состоянием в области функционального программирования и связанной технологии. При написании книги мы старались сделать ее, насколько это возможно, замк­ нутой, хотя, очевидно, желательно применение и некоторого опыта обычного программирования. Те читатели, кто уже зна­ ком с функциональными языками, но хотел бы почерпнуть сведения по реализации и оптимизации, могут пропустить пер­ вую часть книги и перейти сразу к частям II и III. Благодарности Мы в значительной степени обязаны членам секции функ­ ционального программирования Империал-Колледжа за много­ численные предложения и замечания по материалу этой книги. Мы бы хотели особо поблагодарить Хелен Пулл и Линдона Уайла, посвятивших многие часы чтению черновиков и сделав­ ших множество конкретных и существенных замечаний, оказав­ шихся весьма ценными. Империал-Колледж Август 1987

Тони Филд Пити Харрисон

Часть I

ПРОГРАММИРОВАНИЕ С ПОМОЩЬЮ ФУНКЦИЙ ВВЕДЕНИЕ

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

Предисловие

9

неизбежное развитие языковой технологии отрывает процесс программирования от выделения в целом модели вычисления. Только тогда можно будет отойти от взгляда на программу как на средство получения результата и вместо этого развивать тенденцию, когда программа является ясным и выразительным утверждением, которое и должно быть ответом, игнорируя при этом весь длинный путь подсчета результатов. В этом случае основная идея процесса программирования такова: «Я скажу — что, а вы разработаете — как»; иными словами, она более осно­ вывается на абстрактной спецификации проблем, нежели на описании методов их решений. В последнее время появилось довольно большое число язы­ ков, преуспевших в отходе от формы традиционного импера­ тивного программирования; примером такого рода могут слу­ жить аппликативные, или функциональные, языки, описанию которых и посвящена эта книга. Функциональные программы строятся из «чистых» математических функций, которые по сравнению с функциями многих императивных программ сво­ бодны от побочных эффектов, т. е. их вычисление не может изменить среду вычислений. Иными словами, не существует назначаемого состояния программы. Из-за этого нет возможно­ сти больше программировать «с помощью эффекта», так что величина, которая должна быть вычислена программой, и сама программа редуцируются к одному и тому же результату. Вы­ полнение программы тогда становится процессом изменения формы требуемой итоговой величины так, что «8 -(- 1» можно заменить на «9»; при этом обе они будут обозначать одну и ту же величину. Первая часть книги посвящена неформальному и интуитив- . ному введению в функциональные языки и функциональные концепции программирования. Первоначальная задача состоит в том, чтобы показать, что программирование с помощью функ­ ций не только возможно, но также вполне естественно и приво­ дит к программам почти всегда более кратким, нежели анало­ гичные императивные программы, более простым в написании и в объяснении. При изложении будем исходить из того, что читатель не имеет айриорных знаний о функциональных языках, но достаточно хорошо злаком с основами традиционных спосо­ бов программирования; например, для иллюстрации важных различий между императивным и функциональным стилем часто будут даваться ссылки на программы на языке Паскаль. Для логичности на протяжении всей книги будет использоваться общий код, названный функциональным языком программиро­ вания Норе, так что большая часть материала в этой части

10

Часть I

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

Глава 1 ВВЕДЕНИЕ В ФУНКЦИИ

В этой главе предполагается объяснить, что такое матема­ тические функции и как они используются для построения про­ грамм с помощью метода композиции. Будет рассмотрено также, как для решения тех или иных проблем функция пред­ ставляется в виде черного ящика; как эти ящики можно компо­ новать для построения более мощных функций, которые в свою очередь можно также рассматривать в качестве черных ящиков для построения еще более сложных функций. Затем мы иссле­ дуем свойство функциональности и обратимся к проблеме язы­ ков, не обладающих этим свойством. Используя обычный язык типа Паскаля, мы увидим странные свойства поведения про­ граммы, возникающие потому, что язык допускает побочные эффекты, позволяющие изменять состояние вычисления. 1.1. Чистые функции В математике функция есть нечто, что обеспечивает отобра­ жение объектов из множества величин, названного областью определения функции (доменом), в объекты из некоего целевого множества, именуемого областью значений функции или диапа­ зоном значений функции. Простой пример функции — отображение любого заданного целого числа в одно из значений плюс, минус или нуль соответ­ ственно в зависимости от того, является ли данное число поло­ жительным, отрицательным или равным нулю. Назовем эту функцию sign. Областью определения функции sign является множество целых чисел, а диапазоном значений — множество величин {плюс, минус, нуль). Для более наглядного представ­ ления покажем элементы из области определения функции, об­ ласть значений функции, а также отображение каждого эле­ мента (рис. 1.1).

12

Часть I. Глава 1

sign( sign( Sign( sign( sign( sign( sign(

— 3 ) =

minus

- 2 ------

— 2 ) =

minus

- 1 -----

- 1 )=

minus

0—

0 )

2 ------

minus

zero

1 ------

1)

= =

zero plus

2 )

=

plus

3—

3)

=

plus

4—'

Рис. 1.1. О тображ ение функции sig n .

Отметим, что функция sign отображает каждый элемент из области определения функции в единственный элемент из ее области значений. Эта важная деталь означает, что не сущестйует неоднозначности в том, в какой элемент из области зна­ чений функции отображается заданный элемент из области определения. По этой причине все функции называют правильно определенными или детерминированными. Отображение элементов из области определения в элементы области значений можно представить в ином виде—используя множество уравнений, по одному уравнению на каждый эле­ мент определения: sign( —3) = минус sign( —2) — минус sign( — 1) = минус sign(O) — нуль sign( 1 ) = плюс sign( 2) = плюс sign( 3) = плюс Такое представление довольно неудобно, поскольку для полной характеристики функции (в данном случае sign) требуется бесконечное число уравнений. Однако, как убедимся в дальнейшем, без использования уравнений иногда трудно обой­ тись.

Введение в функции

13

Третий способ описания отображения, порождаемого функдней, состоит в определении всего лишь одного правила:

где х называется параметром функции sign и представляет любой заданный элемент из области определения функции. Основная часть правила (т. е. правая его часть) просто опре­ деляет, в какой элемент из диапазона значений функции ото­ бражается параметр х. В данном случае правило для функции sign представляет собой бесконечное число отдельных уравне­ ний, по одному для каждой величины из области определения функции. Поскольку эта функция справедлива для всех воз­ можных элементов домена, ее называют полной функцией. Если же в правиле опущен один или несколько возможных элемен­ тов домена — это частичная функция. Примером частичной функции может служить минус, если х < 0, s ign2( х ) = плюс, если х > 0. на области определения, состоящей из целых чисел; частичной она является потому, что не существует правила, описывающего случай, когда х = 0. При этом говорят, что функция sign2 не определена при х = 0. Функцию, подобную sign, можно рассмотреть и как черный ящик, где вход представляет собой параметр функции, а выход — результат вычислений. Для sign выходом будет являться одно из значений: минус, нуль или плюс, в зависимости от значения числа, поданного на вход. Выходное значение определено пра­ вилом для функции sing, являющимся составной частью чер­ ного ящика. Например, если на вход поместить число 6, то на выходе получим плюс: 6

плюс

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

14

Часть I. Глава 1

Процесс подачи на вход функции фактического параметра на­ зывается применением функции, и говорят, что функция sign применена к 6, т. е. правило для sign использует 6 в качестве фактического параметра. В зависимости от контекста нам часто придется ссылаться на формальные и фактические параметры функции как на ее аргументы. Описанное применение функции можно записать в виде математического обозначения: sign(6). Говорят, что это выражение принимает значение плюс. Данный факт можно записать в виде sign( 6 )-> плюс. Это означает, что черный ящик выдает величину плюс на вы­ ходе тогда, когда 6 подается на его вход. Знак можно чи­ тать как «равно», поскольку выражение sign (6) является про­ сто альтернативным обозначением для величины плюс. Вот еще несколько примеров: sign( —4) —> минус, sign( 0 )-> нуль. Идея того, что функция является механически зафиксирован­ ным правилом преобразования входов в выходы,-—есть одно из фундаментальных положений функционального программи­ рования. Черный ящик является конструктивным блоком для функциональной программы, и с помощью объединения таких ящиков можно порождать более сложные операции. Такой про­ цесс «объединения» ящиков называется функциональной ком­ позицией. Для иллюстрации процесса функциональной композиции возьмем функцию шах, которая вычисляет максимум из двух чисел ш и п : шах(ш, п) = ш, если m > п, = п в противном случае. Областью определения функции max является множество пар чисел, а областью ее значений — множество чисел. Можно рас­ сматривать max как черный ящик и использовать его для вы­ числения максимума двух чисел. Например: запишем 1 7

в виде т а х ( 1 ,7)-*-7.

7

Введение в функции

15

Можно также использовать шах как блок для более слож­ ной функции. Предположим, требуется построить функцию, которая бы находила максимум не из двух чисел, а из трех. Эту новую функцию (назовем ее гпахЗ) определим следующим об­ разом: шахЗ(а, Ь, с) = а, еслиа ^ Ь и а > с или а ^ с и а > Ь, b, если Ь ^ а и Ь > с или Ь ^ с и Ь > а, c, если с ^ а и с > Ь или с^&Ь и с > а, а в противном случае. («В противном случае» — это если а = b = с.) Это довольно неудобное определение! Гораздо более эле­ гантный способ определения функции т а х З состоит в исполь­ зовании уже определенной функции max: а b с

Запишем это следующим образом: шахЗ(а, b, с) = т а х (а , тах(Ь , с)). Поскольку т а х З обеспечивает детерминированное отображение тройки чисел в число, можно рассматривать ее как черный ящик, работающий по своему собственному правилу: а Ь с а Ь с

L J тахЗ

16

Часть I. Глава 1

Теперь можно забыть о том, какая работа совершается внутри этого нового ящика и использовать его как единицу вычисления или как конструктивный блок для других более сложных функций. Например, при вычислении max 3(1, 4, 2) получим 4. Так же можно использовать ее для построения дру­ гих функций, например функции, вычисляющей знак максималь­ ного из четырех чисел а, Ь, с и d, применяя уже известные нам функции sign и шах: SM4( а, Ь, с, d) = sign(max(a, тахЗ(Б, с, d ))). В виде черного ящика это можно представить следующим об­ разом:

a

b

e

d

a b e d

-Г SM4

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

Введение в функции

17

1.2. Функциональность Фундаментальное свойство математических функций, кото­ рое дает нам возможность собрать воедино черные ящики,— это функциональность (прозрачность по ссылкам). Существует несколько интуитивных прочтений этого термина, но по суще­ ству он означает, что каждое выражение определяет един­ ственную величину, которую нельзя изменить ни путем ее вы­ числения, ни предоставлением различным частям программы возможности совместно использовать это выражение. Вычисле­ ние выражения просто изменяет форму выражения, но не из­ меняет его величину. Все ссылки на некоторую величину экви­ валентны самой этой величине, и тот факт, что на выражение можно ссылаться из другой части программы, никак не влияет на величину этого выражения. Функциональность (прозрачность по ссылкам) определяет различие между математическими функциями и функциями, которые можно написать на импера­ тивных языках программирования, таких, как Паскаль, по­ скольку эти языки дают функциям возможность ссылаться на глобальные данные и разрешают применять (разрушающее) присваивание, что может привести к изменению значения функ­ ции при повторном ее .вызове. Такие динамические изменения в величине данных часто именуются побочными эффектами. Благодаря им значение функции может изменяться, даже если ее аргументы и остаются без изменения всякий раз, когда к ней обращаются. Это приводит к тому, что функцию трудно исполь­ зовать, поскольку для того, чтобы определить, какая величина получится при выполнении функции, необходимо рассмотреть текущую величину глобальных данных. Это же в свою очередь требует рассмотрения истории вычисления для определения того, что порождает величину глобальных данных в каждый момент времени. Говорят, что императивные языки являются нефункциональными. Для иллюстрации их нефукциональности рассмотрим пример программы, написанной на языке Па­ скаль: program example ( output); var flag : boolean ; function f ( n : integer ): integer ; begin if flag then f := n else f : = 2 *n; flag not flag end; 2



1473

IS

Часть I. Глава 1

begin flag : = true; writeln( f( 1 ) + f(2 )); w riteln(f(2)-j-f( 1)) end. После выполнения этой программы на терминал будут выве­ дены два числа: 5 и 4. Однако это довольно странно, поскольку с математической точки зрения коммутативность сложения поз­ воляет заменять х + у на у + х для любых х и у, а в этой программе мы видим, что на языке Паскаль выражение f(l) + + f(2) дает результат, отличающийся от f(2) + f(l)l Однако дело здесь не в изменении самой функции + . Проб­ лема состоит в том, что функция f, определенная выше, сильно отличается от математических функций, которые мы до сих пор рассматривали. Наше представление о математической функции сводится к тому, что мы представляли ее в виде чер­ ного ящика, вычисляющего выходные величины исключительно исходя из входных величин. Функция же, определенная в при­ веденной выше программе, является классическим примером функции, которая в равной степени зависит от глобальных данных, и от своих собственных параметров. Следует обратить внимание на то, что величина глобальной переменной (т. е. flag) в нашей программе на языке Паскаль имеет возможность из­ меняться, и именно это уничтожает свойство функциональности языка. Значение же элементарной функции + не изменяется, так как всегда обозначает функцию, суммирующую некоторые величины. Источником таких проблем в программах на языке Паскаль является операция присваивания flag := n o t flag, изменяющая величину flag. Если перед выполнением данной операции значение flag было «истина», то после выполнения оно становится «ложь», и наоборот. Операции, подобные этой, в математике не разрешены, поскольку математические рассуж­ дения базируются на идее равенства и возможности замены одного выражения другим, означающим то же самое, т. е. опре­ деляющим ту же величину. Например, выражение 4 + 8 можно заменить на 12, поскольку оба выражения являются обозначе­ нием одной и той же величины, т е. числа 12. Тот факт, что в программе не содержится операции присваивания, является характеристикой функциональных программ. Вместо представ­ ления о переменной как о величине, которая может периоди­ чески изменяться путем присваивания ей различных значений, переменные в функциональной программе рассматриваются как

Введение в функции

19

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

Глава 2 ВВЕДЕНИЕ В ФУНКЦИОНАЛЬНОЕ ПРОГРАММИРОВАНИЕ. ЯЗЫК НОРЕ

В предыдущей главе мы познакомились с идеей программи­ рования с помощью функций. В последующих трех главах бу­ дет описана техника функционального программирования с ис­ пользованием языка Норе; единый язык используется для со­ гласованного изложения всего материала. Следует отметить, что версия языка Норе, приведенная здесь, является расширением оригинального языка Норе, описанного в работе [19]. В этой главе мы коснемся некоторых элементарных поня­ тий языка Норе, начиная с простых объявлений функций и определений и кончая определенными пользователем типами данных. Описание более сложных элементов Норе отложим до глав 3 и 4. Мы не будем пытаться описать все возможности языка, но того, что мы дадим, будет достаточно для полного понимания методов функционального программирования и для того, чтобы разобраться в примерах программ, приведенных в частях II и III. Полное описание языка Норе можно найти в книге [70], а краткие сведения о нем — в приложении А. 2.1. Введение понятия функции В гл. 1 было показано, как можно определить математиче­ скую функцию с помощью правила, которое указывает, как функция должна обрабатывать свой аргумент или аргументы, чтобы получить требуемый результат. В языке Норе функция описывается в два этапа. Первый этап включает в себя напи­ сание типа функции, точно задающего область определения функции и диапазон ее значений. Второй—-описание того, ка­ кую операцию выполняет функция. Рассмотрим простейшую функцию: возведение целых чисел в квадрат. Сначала дадим функции имя square и объявим ее тип: dec square : num—>-num;

Введение в функциональное программирование. Язык Норе

21

dec — зарезервированное слово (все зарезервированные слова будем выделять жирным шрифтом), задающее начало раздела объявления типа. За ним следуют имя функции, тип которой определяется, т. е. square, и выражение типа, определяющее тип функции. Двоеточие (:) разделяет имя функции и выраже­ ния типа, и все определение заканчивается точкой с запятой (;). Итак, написанная строка читается следующим образом: ’’square является функцией из num в num”; num есть базовый тип (т. е. предопределенный или встроенный), предусмотренный для представления целых чисел; в дальнейшем мы встретимся с другими базовыми типами языка Норе. Объявление типа любой функции имеет следующий формат: dec паше : А — В ; А определяет тип аргумента (или аргументов) функции (об­ ласть определения функции), а В — тип результата, получен­ ного после выполнения функций (диапазон значений функции). Норе— это пример языка строгой типизации, в котором функция определена для выполнения какой-то операции над объектами определенного типа. Таким образом, применение функции к объекту несоответствующего типа рассматривается как ошибка. Однако, хотя тип каждой функции в языке Норе должен быть объявлен явно, это не является общим требова­ нием для строгой типизации. В гл. 5 будет дан пример языка, в котором тип каждой функции выводится автоматически, т. е. без явного объявления в программе. Однако идеология языка Норе такова, что объявление типа считается частью интеллек­ туального процесса программирования и потому является обязательным; альтернативная идея заключается в минимиза­ ции числа определений, т. е. чем меньше их требуется, тем лучше. Принципиальное преимущество строгой типизации заклю­ чается в том, что многие программные ошибки могут быть устранены прежде, чем программа будет запущена на выпол­ нение. Огромное количество программных ошибок происходит из-за того, ч_то в функциях используются неправильные типы аргументов. В случае же если язык строго типизирован, при проверке типов программисту выдается сообщение об ошибках, так как любое несоответствие типов в программе будет сразу же обнаружено. Такая организация представляется более удоб­ ной, поскольку в противном случае ошибка проявляет себя уже во время выполнения программы и ее анализ и локализация требует значительных затрат времени. К вопросу о проверке ти­ пов мы вернемся в следующих главах, где увидим, что стро­ гая типизация исходной программы оказывает значительное

22

Часть I. Глава 2

влияние на эффективность реализации. Сам же алгортим про­ верки типов описан в гл. 7. Обратимся теперь к нашему примеру; в определении функ­ ции square описывается, что она делает со своим аргументом. Определение имеет следующий формат: --------square( х ) < = х * х ; Это очень похоже на математическую запись, использованную ранее. Все определения функций в тексте программы выделяют­ ся тем, что перед ними стоят три прочерка--------- , а сами опре­ деления состоят из левой и правой частей, разделенных стрел­ кой < = . В левой части записаны имя определяемой функции и список имен ее формальных параметров (или параметра); правая же часть (обычно называемая телом функции) харак­ теризует то действие, которое необходимо проделать с этими параметрами. Вся строка определения заканчивается точкой с запятой. В нашем примере тело функции square (х) состоит из выражения х*х. Знак (*) в этом выражении является при­ мером примитивной функции. То, что символ (*) появился между ее аргументами, объясняется удобством записи; в этом случае говорят., что (*) — инфиксная функция или инфиксный оператор; (*) можно было бы определить и как префиксную функцию, поскольку возможна другая равнозначная запись *(х, х), однако мы выбрали инфиксную запись х*х, которая хорошо знакома из элементарной математики. Полное множе­ ство примитивных функций (операторов), поддерживаемых в языке Норе, приведено в приложении А. Теперь, после того как функция square определена, ее можно использовать для возведения в квадрат целых чисел, например square(3) Вычисление этого выражения происходит следующим об­ разом: square(3) -*-3*3 из определения функции, -> 9 из предопределенной семантики примитивной функции *. (Стрелка означает «равно», хотя, очевидно, и это является упрощением: выражение, стоящее справа от стрелки, есть ре­ зультат упрощения выражения, стоящего слева от стрелки.) Как все функции языка Hope, square является «чистой» функцией, поэтому можно быть уверенным, что выражение square(3) всегда равно 9.

Введение в функциональное программирование. Язык Норе

23

Теперь обратимся к более сложному примеру. В гл. 1 мы определили функцию шах следующим образом: max(m, n) = m, если ш > п, = п в противном случае. Областью определения этой функции является множество пар чисел, а диапазоном значений — множество чисел. Это от­ ражено в объявлении типа эквивалентной Норе-функции: dec шах : num # num

num ;

В этом объявлении тип выражения слева от стрелки означает, что шах в качестве аргументов использует пару чисел в отличие от функции square, где использовался лишь один аргумент. Символ решетки ( # ) обозначает декартово произведение ти­ пов, в данном случае-—пары чисел. Правило для шах выглядит следующим образом: --------max(m, n ) < = if m > n then m else n; Функция > (т. e. «больше») — еще один пример примитивной функции языка Норе. И ... then ... else ... — условная кон­ струкция Норе, не нуждающаяся в комментариях. Теперь можно использовать шах для определения других функций, например функции шах 3, приведенной в гл. 1: dec шахЗ : num # num # num -> num ; --------max3(a, b, c ) < = m a x ( a , max(b, c )); Заметим, что в объявлении типа функции т а х З указано три аргумента вместо двух. Само понятие обозначения величины важно для отражения свойства функциональности. Например, когда говорят, что square(3) обозначает величину 9, это значит, что там, где встречается square (3), его можно просто заменить на вели­ чину 9. Таким образом, хотя форма выражения может видоиз­ меняться, величина, которую оно представляет, остается прежней. 2.2. Кортежи Слово «кортеж» обозначает набор связанных величин или объектов. Фактически мы уже встречались с несколькими при­ мерами кортежей, хотя и неявно. В выражении та х ( 1 ,2 )

24

Часть I. Глава 2

функцию max мы рассматривали как функцию двух аргумен­ тов. Хотя, если быть более точным, в Норе надо рассматривать аргументы как один кортеж величин, а не как совокупность отдельных величин. Выражение типа функции max dec max : num # num —>-num ; следует читать так: «функция max берет двухэлементный- кор­ теж чисел (подчеркнуто) и возвращает одно число». Не суще­ ствует понятия кортежа, состоящего из одной величины. Дей­ ствительно, если функция берет единственный аргумент, не являющийся кортежем, тогда круглые скобки, в которые заклю­ чен аргумент, при определении функции и ее применении мож­ но опустить. Это означает, что оба выражения squarel2 и square(12), относящиеся к ранее определенной функции square, равнознач­ ны и могут иметь место. Однако на протяжении всего изложе­ ния книги будем пользоваться вторым из приведенных форма­ тов. В случае же когда функция имеет один аргумент, скобки следует рассматривать просто как ограничители, а не как син­ таксическое обозначение, имеющее место в кортеже. Такое разграничение между списками аргументов' и корте­ жами не очень важно; действительно, обычно будем говорить, что функция имеет «больше чем один аргумент», и это озна­ чает, что она имеет кортеж аргументов. Однако такая поста­ новка в дальнейшем нам очень пригодится. До сих пор мы имели дело с функциями от одного или бо­ лее аргументов, имеющими единственный результат. Аналогич­ ным образом можно определить функцию, которая для любого аргумента (или аргументов) выдает результат с бол,ее чем одним компонентом, т. е. кортеж. Примером такой функции может служить IntDiv, которая при целочисленном делении для заданной пары аргументов вычисляет частное и остаток. Например: IntDiv( 7, 3) даст пару (т. е. двухэлементный кортеж) (

2, 1 )

На языке Норе определение функции IntDiv будет выглядеть следующим образом: dec IntDiv : n u m # n u m - > n u m # num ; --------IntDiv(m, n ) < = ( m div n, m mod n );

Введение в функциональное программирование. Язык Норе

25

где div и mod — инфиксные примитивы языка Норе. Следует отметить, что тип аргумента функции IntDiv такой же, как и результата выполнения этой функции, а именно n u m # n u m ; если мы применим IntDiv к двум аргументам (кортежу), то получим два результата (сформированные в виде отдельного кортежа). Теперь, поскольку тип результата выполнения функции IntDiv такой же, как и аргумента, необходимого, например, для функции шах, можно объединить эти две функции. Итак, в ре­ зультате шах( IntDiv( 11, 4)) получаем 3, поскольку IntDiv (11, 4) возвращает значение (2,3), a max (2, 3) равна 3. В дополнение к базовому типу nurn, обозначающему мно­ жество целых чисел, в языке Норе имеются и другие базовые типы: truval, real и char. Тип truval (сокращение от truth value.О) представляет тип булевых данных и в качестве своих элементов принимает значение true (истина) или false (ложь); real обозначает множество действительных чисел, a char—■ множество символов. Для того чтобы показать, как эти базо­ вые типы могут быть использованы на практике, введем функ­ цию, названную analyse, проводящую простой анализ действи­ тельных чисел. Заданное действительное число г функция analyse преобразует в кортеж с тремя компонентами: 1) знак «—» или «+ » в зависимости от того, меньше или боль­ ше число г числа 0, 0 соответственно; 2) значение истинности, показывающее, принадлежит ли дан­ ное число г диапазону (—1,0) — (+ 1 ,0 ) включительно; 3) «ближайшее» целое число к данному. Описание поставленной задачи тут же подсказывает и тип тре­ буемой функции: dec analyse : real -> char # truval # num ; Тело функции analyse состоит из кортежа с тремя выражения­ ми, каждое из которых соответствует части результата: --------analyse(r) < = ( i f r < 0 then ' — ' else ' + ( r > = —1.0) and ( r = < 1.0), round( r )); Первый компонент является условным выражением, возвра­ щающим один из знаков «—» или «+ » в зависимости от значео T ruth value — истинностное значение. — Прим. ред.

26

Часть I. Глава 2

ния г, второй является выражением булева типа, и третий — простое применение примитивной функции round, округляющей действительное число до ближайшего целого. Например, выра­ жение analyse( —1,04) будет иметь значение (’—’, false, —1), поскольку —1,04 являет­ ся отрицательным числом (следовательно, знак минус), не при­ надлежит диапазону (—1,0) — (+ 1 ,0 ) (поэтому false) и при округлении дает — 1. Заметим, что в этом определении использована примитив­ ная функция > , аргументами которой являются два действи­ тельных числа, тогда как в определении функции max, данном ранее, функция была использована для операций над целыми числами. Это может показаться довольно странным, поскольку мы предположили, что все функции (включая примитивные) строго типизированы, т. е. могут быть применены лишь к объ­ ектам определенного типа, как предписывается соответствую­ щим определением типа. Однако было бы довольно неудобно при необходимости использовать различные символы для опи­ сания функции «больше» в зависимости от того, являются ее аргументы действительными или целыми числами, или же в том случае, когда один аргумент — действительный, а другой — цедый. В Норе эта проблема решается использованием пере­ крытия, которое дает возможность одному символу функции иметь то количество значений, которое требуется в зависимости от той или иной ситуации. Можно представить четыре различ­ ные версии функции > : dec > : num # num - » truval; dec > : num # real -> tru v al; dec > : real # num -> tru v al; dec > : real # real -*■truval ; с соответствующими (предварительно заданными) определения­ ми. Хотя мы и используем один и тот же символ > в функциях analyse и max, примитивная функция, к которой обращаемся в каждом случае, фактически различна. Такое же правило при­ меняется и для других функций сравнения и арифметических функций, подобных + и —, которые могут оперировать как це­ лыми, так и действительными числами. Кроме того, необходимо отметить, что элементы кортежа могут быть смешанного типа; компоненты кортежа-результата могут состоять из любой тройки значений таким образом, что первое значение принадлежит множеству символов, второе —

Введение в функциональное программирование. Язык Норе

27

множеству булевых величин, а третье— множеству целых. Од­ нако кортежи (true, 'а ', 'Ь ') и ('a ', true, 'Ь ') не являются кортежами одного типа, хотя оба они имеют два символьных значения и одно булево. 2.3. Рекурсивные функции Предположим, нам нужно написать функцию для подсчета суммы п первых неотрицательных целых чисел. Можно присту­ пить к написанию очень большого определения функции: dec sum : num —>■num ; --------s u m ( n ) < = i f n = 0 then 0 else if n = 1 then 1 else if n = 2 then 3 else и т. д. Трудность здесь, конечно, заключается в том, что функция имеет бесконечно много случаев, которые нужно рассматривать при ее вычислении (или, точнее, конечное число случаев, опре­ деляемое арифметическими ограничениями реализации). С по­ мощью традиционного языка программирования задача может быть решена с использованием цикла какой-либо формы, на­ пример на языке Паскаль задача решается с помощью следую­ щей функции: function sum( n : integer): integer; var loopcounter, acc : integer ; begin acc : = 0; for loopcounter : = 1 to n do acc : = acc -f- loopcounter; sum : = acc end; Здесь для обновления значения аккумулятора асе, который при выходе из цикла содержит требуемый результат, исполь­ зуется цикл for. В функциональном языке не существует цик­ лических конструкций и оператора присваивания, изменяющего значение счетчика. В таких языках задача решается с исполь­ зованием рекурсивной функции. Рекурсивная функция — это функция, вызывающая сама себя. Конечно, вызываемая функ­ ция должна решать более простую задачу, чем вызывающая, иначе рекурсия никогда не закончится, поэтому одна из труд­ ностей при написании рекурсивной функции состоит в том,

28

Часть I. Глава 2

чтобы выделить из текущей задачи одну или несколько более простых задач, аналогичных исходной. Функция, таким образом, может вызывать сама себя для решения более простых задач, а окончательное решение может быть получено из возвращае­ мых результатов. Чтобы решить нашу задачу нахождения сум­ мы с использованием рекурсии, рассмотрим структуру выраже­ ния для суммы первых п неотрицательных целых чисел: sum( п ) == 1 + 2 + 3 + 4 + . . . -)- ( п — 1) — J—п Из этой формулы можно увидеть связь между суммой пер­ вых п целых и суммой первых п — 1 целых: если удалить + п в конце выражения, останется выражение, эквивалентное sum (n— 1). Такую связь часто называют рекуррентным отно­ шением, отсюда вытекает, что, имея целое число п, можно ис­ пользовать определенную нами функцию sum для суммирова­ ния первых п — 1 целых и затем получить решение, добавив п к результату, возвращенному функцией sum (n— 1). Кроме того, мы должны определить так называемый базовый случай, специфицирующий, где рекурсивный процесс должен остано­ виться. Для нашей функции базовый случай имеет место, когда аргумент равен 0, и мы ожидаем, что функция при этом воз­ вратит 0 (сумма нуля целых чисел равна нулю). Все это вме­ сте взятое дает следующее определение: --------sum(n) < = if n = 0 then 0 else sum(n — 1) + n; Хотя данное определение не требует, чтобы мы думали о том, как работает рекурсия, можно видеть эффект рекурсии из следующего примера: sum( 4) -> sum(3) + 4 -> (sum (2) + 3) + 4 -> ( ( sum( 1) + 2) + 3) + 4 -> (((s u m (0 ) + l ) + 2) + 3) + 4 - > ( ( ( ( 0 + l ) + 2) + 3) + 4) -> 10 Сравнивая определения sum на языке Hope и на языке Паскаль, можно видеть наиболее важное различие между импе­ ративным и функциональным решениями задачи. Чтобы понять решение на языке Паскаль, мы должны понять, что машина бу­ дет делать при выполнении каждого оператора программы. При функциональном решении, с другой стороны, мы не должны

Введение в функциональное программирование. Язык Норе

29

думать о том, как программа будет выполняться на компьютере; отсутствует всякое упоминание об изменяемом состоянии про­ граммы или о последовательном выполнении инструкций. Функ­ циональное решение является фактически формулировкой самой задачи, а не рецептором ее решения, и именно в этом смысле мы говорим о функциональной программе как о спецификации того, что нужно сделать вместо последовательности инструкций, описывающих, как это сделать. Мы увидим еще много примеров рекурсивных функций в следующих разделах этой главы и в остальных главах книги. Несмотря на то что рекурсия ведет к очень абстрактным -и сжа­ тым решениям числовых задач, ее реальная мощность стано­ вится очевидной при рассмотрении функций, работающих с ти­ пами данных пользователя. Этот вопрос будет рассмотрен в разд. 2.6. 2.4. Объявляемые инфиксные операторы Функции, рассмотренные нами ранее, записаны в префикс­ ной нотации. В этом случае символ функции предшествует ее аргументу или аргументам. Однако из элементарной математики нам больше знакомы выражения вроде 1+3 Здесь функциональный символ расположен между операндами и поэтому называется инфиксной функцией, или инфиксным оператором. При написании программ иногда удобно определить соб­ ственную инфиксную функцию, и это может быть сделано в языке Норе с помощью зарезервированного слова infix. Оно вводит имя нового оператора и его приоритет. Приоритет — это целое число от 1 до 10, определяющее предпочтение оператора относительно других операторов программы. В качестве простого примера рассмотрим выражение 1+3*2 Здесь мы обычно предполагаем, что * имеет более высокий приоритет по отношению к + , поэтому выражение читается как 1 + ( 3 * 2 ) , а не ( 1 + 3 ) * 2 . В языке Норе приоритеты операто­ ров + и * соответственно равны 5 и 6 (приоритеты всех ин­ фиксных примитивов даны в приложении А). Более высокий приоритет * означает такую расстановку скобок в рассмотрен­ ном выражении, какую мы ожидаем.

30

Часть I. Глава 2

Чтобы ввести новый оператор ОР с приоритетом Р, мы пишем infix OP : Р ; Синтаксис объявления типа оператора точно такой же, как у обычной функции, за исключением того, что объявление со­ держит всегда два аргумента: dec OP : Typel # Т у р е 2 —>ТуреЗ ; где Typel, Туре 2, ТуреЗ — выражения типа. Аналогично, когда мы определяем ОР, левая часть определения сама записывается в инфиксном формате: --------P t ОР Р2< = . . . ; где Pi и Р2— формальные параметры ОР. Для иллюстрации этого механизма приведем объявление и определение инфиксного оператора f, означающего возведе­ ние в степень, т. е. дающего для двух целых аргументов х и у значение ху. В математике возведение в степень имеет более высокий приоритет по отношению к другим арифметическим операциям, так что выражение а * Ьс читается а*(Ьс), а не ( а * Ь ) с. Чтобы отразить этот факт, нам следует назначить приоритет 7 оператору f, т. е. на единицу больше приоритета оператора * infix t : 7 ; dec f : num # num -> n u m; Определение f рекурсивно и основано на том, что ab = а * ab_1 при базовом случае а ° = 1. На языке Норе оно выглядит так: --------х f у < = if у = 0 then 1 else х * х f ( у — 1); Например: 3 + 7 f 2 —6 3 *5 | 2 2.5.

дает в результате 46 дает 75

Квалифицированные выражения

Рассмотрим следующую функцию: dec f : num->-mim ; --------f(х ) < = g( square( max (x, 4 ) ) ) + (if x —< 1 then 1 else g(square(max(x, 4)))), '

Введение в функциональное программирование. Язык Норе

31

В этом определении подвыражение g (square (шах (х, 4))) встре­ чается дважды. Более того, если аргумент f (т. е. х) больше 1, то тело f эквивалентно g(square(max(x, 4) ) ) + g( square(max(х, 4 ) ) ) ; что приводит к необходимости дважды вычислять подвыраже­ ние g(square(max(x, 4))). Это довольно расточительно, так как мы знаем, что результат обоих вызовов будет одним и тем же. Можно избежать этих повторений двумя способами. Во-пер­ вых, мы можем определить вспомогательную функцию, скажем И, имеющую повторяющееся подвыражение в качестве пара­ метра: --------f(x) < = f l ( g ( s q u a r e ( ma x ( x , 4))), х); --------fl(a, b ) < = a - f (if b = < 1 then 1 else a ); Здесь мы основываемся на том факте, что аргументы функции вычисляются не более 1 раза (это станет более ясным из гл. 6). Второй способ заключается в использовании так называе­ мого квалифицированного выражения, оно позволяет нам при­ своить выражению имя и затем использовать это имя так же, как мы используем формальный параметр. В некотором отно­ шении можно рассматривать этот механизм в качестве расши­ рения существующего набора формальных параметров. В языке Норе существуют два эквивалентных вида квалифицированных выражений: конструкция let: let (имя) = = (выражение)! in (выражение)2 и похожая на нее конструкция where: (выражение^ where (имя) = = (выражение)! (выражение)! иногда называют квалифицирующим выраже­ нием, или квалификатором, а (выражение)2— результантом. Обе эти конструкции имеют квалификатор, на который можно ссылаться с помощью имени в результанте. Функция f теперь может быть записана в виде --------f( х ) num ; --------quot( q, r) < = q ; dec rem : num ф num -> num ; --------- rem( q, r ) < = r ; Например: let pair = = IntDiv( x, y) in quot( pair) * у + rem( pair) Однако мы можем использовать квалифицированное выражение для декомпозиции кортежа, полученного из IntDiv следующим образом: let (q, г ) = = IntDiv(x, у) in q* y + r Это выражение именует первый элемент результирующего кор­ тежа символом q и одновременно второй элемент символом г. Это простой пример того, что в языке Норе называется сопо-

Введение в функциональное программирование. Язык Норе

33

ставлением с образцом; в приведенном выражении мы сопостав­ ляем пару (q, г) с результатом, полученным из функции IntDiv, например (2,3). В результате сопоставления символу q припи­ сывается величина 2, а символу г величина 3. Мы иногда будем использовать термин связка и говорить, что q привязано к 2, а г к 3. Более глубоко эти вопросы рассмотрим в следующем разделе. 2.6. Типы данных, определяемые пользователем Итак, все функции, введенные в наших примерах, оперируют с объектами таких основных типов, как num, real и char. В большинстве приложений, однако, необходима возможность использования расширенного набора типов данных за счет вво­ димых программистом новых гипов. Это так называемые типы данным, определяемые пользователем. В дополнение к основным типам данных многие языки про­ граммирования предлагают большой набор средств для по­ строения более сложных структур данных. Язык Паскаль, на­ пример, позволяет определить записи и указатели, благодаря чему можно использовать записи для создания компонентов структуры, а указатели — для ссылок на эти записи. При та­ ком подходе в языке Паскаль можно ввести списки, используя записи для представления каждого элемента списка, указатели для ссылок на них и специальную величину NIL для обозначе­ ния нулевого указателя. Определение списка как нового типа данных будет в этом случае выглядеть следующим образом: type list = ''c e ll; cell = record head : integer; tail l-eett- L is t end; Графически такое представление списков будет выглядеть как последовательность элементов-прямоугольников, соединен­ ных указателями-стрелками. Например, список, содержащий элементы 1, 2 и 3, будет иметь следующий вид:

Список достраивается путем образования новых записей и заполнения их соответствующими значениями. Ниже в качестве з — и ;j

34

Часть I. Глава 2

примера приводится часть программы, создающая список из единственного элемента — числа 3: N EW (L); with L~ do begin head : = 3 ; tail := NIL end; Суть того, что мы делаем здесь, заключается в манипули­ ровании памятью на довольно высоком уровне: мы подробно описываем элементы списка и заполняем их значениями, необ­ ходимыми для построения структуры. Создание списков в Норе происходит путем определения полностью нового типа данных и составных частей этого типа, а не путем использования неких заранее зафиксированных в самом языке примитивов, таких, как записи и указатели. Пре­ имущество такого подхода заключается в том, что нам следует описать то, как выглядит вводимая структура данных, а не то, как она может быть выражена через существующие типы данных. Теперь предположим, что нам необходимо так описать спи­ сок чисел, чтобы была возможна его передача от функции к функции. Мы не будем задумываться над представлением хранения списков в том виде, который используется в языке Паскаль, а попробуем вместо этого создать рекурсивное описа­ ние структуры списка: Список чисел есть т ак ая структура данных, которая является либо пустой, либо непустой, в случае чего она состоит из некоторого числа (голова спи­ ска) и другого списка чисел (хвост списка).

Синтаксис соответствующего определения в Норе в точности отражает суть этого описания: data Numlist = — nil -^--f cons( num # Numlist); здесь nil и cons называются конструкторами данных, поскольку служат лишь для конструирования нового типа данных. Они неявно определяются при появлении в выражениях data. Кон­ структоры часто называются конструирующими функциями, а конструкторы без аргументов — константами данных. Используя конструкторы, можно переписать определение того же списка из одного элемента: cons(3, nil) Для наглядности в соответствии с термином «конструирую­ щая функция» мы применили конструктор так, как если бы он

Введение в функциональное программирование. Язык Норе

35

и в самом деле был обычной функцией. Однако для конструи­ рующих функций в отличие от других нет существующих пра­ вил, позволяющих манипулировать ими (упрощать, например). Таким образом, выражение cons(3, nil) рассматривается как некоторая величина в том смысле, что оно не может быть упрощено путем применения к нему соответствующих правил. В качестве более сложного примера приведем список, содержа­ щий числа 1, 2 и 3 и выраженный через конструкторы nil и cons: cons( 1, cons(2, cons(3, n il))) Будем называть выражения, образованные при помощи кон­ структоров, термином составные данные. Используя конструкторы nil и cons, можно построить про­ извольно большие списки чисел. Иногда, однако, бывает необ­ ходимо создать списки объектов иного рода, таких, как сим­ волы, вещественные числа или даже списки списков. Для опи­ сания списка символов, например, можно описать новый тип данных CharList: data CharList = = NilCharList + +ConsChars( char # C harList); Мы не можем использовать в описаниях различных типов одно и то же имя конструктора, поэтому здесь мы не используем nil и cons. Можно заметить, что форматы определения CharList и опре­ деления NumList, введенного ранее, идентичны. Это является следствием того, что общий вид списка символов аналогичен виду списка чисел. Норе позволяет избежать необходимости определять новый тип списка для каждого объекта нового типа блаюдаря возможности определения родового или полиморф­ ного (многоформатного) типа данных. Это является отраже­ нием той мысли, что списки произвольных объектов идентичны по структуре. Полиморфизм позволяет нам параметризовать определение списка, т. е. вводить в него тип хранимых объек­ тов в качестве параметра. Ниже приводится полиморфное опре­ деление списка, которое может быть использовано для описания списка объектов произвольного типа: typevar an y ; data list( any) ===== nil + -f cons( any # list( any)); где any называется переменной типа в том смысле, что она является идентификатором, обозначающим некоторый тип. Новые переменные типа объявляются командой typevar, как указано выше, з*

36

Часть 1. Глава 2

С помощью этого единственного определения мы можем использовать nil и cons для создания списков любого типа, например: cons( 1, cons( 2, n i l ) ) cons( ' a ' , cons( ' Ь' , cons( ' с' , n i l ) ) ) cons( n il, cons( cons( 1, nil ), nil ) ) n il

— список — список — список — список

чисел, символов, списков, объектов, тип которых не описан

Отметим, что все элементы данного списка должны быть одного типа. Следовательно, список вида cons(l, cons(/a/, n il)) некорректен, поскольку его компонентами являются и числа, и символы. Для удобства программирования две переменные типа alpha и beta определяются в самом Норе и могут быть использованы в любом месте программы без предварительного объявления. Вследствие того что списки используются очень часто, они также определяются заранее. Пустым конструктором, как и выше, остается nil, а непустым становится конструктор : : (чи­ тается как cons) с приоритетом 7. Записанное в полном виде данное определение списка выглядело бы так: infix ::: 7 ; data list( alpha ) = = nil H—b alpha :: list( alpha); Ниже приводится другое описание указанных выше списков уже при помощи определяемых в Норе конструкторов: 1 :: (2 :: n il) V : : ( 'b ': : ( 'c ': : n i l ) ) n il:: (( 1 :: n il) :: n il) nil Hope позволяет использовать различные сокращения в выраже­ ниях для списков. Так список ei ::(е 2::(е 3::( . . . :: ( е„ :: n il) . . . ))) может быть записан в виде Геь е2, е3, . . . , еп] Далее, если все ei являются символами, то мы можем объеди­ нить их, заключив в двойные кавычки. Например, список 'Н ':: ( 'о ':: ( 'р ':: (V :: n il)))

Введение в функциональное программирование. Язык Норе

37

может быть записан с использованием этого сокращения как ['Н', V , У , V ] или в виде строки "Норе" Существует и другой способ описания данных, реализуемый в Норе и заключающийся в произвольном наименовании типов данных и их комбинаций (кортежей). Представим, например, что нам необходимо создать набор функций, обрабатывающих координатные пары (х,у). Вместо определения координатной пары как real # real можно ввести новый тип данных r e a l # real при помощи опе­ ратора type: type Coordinate = = real # re a l; и использовать идентификатор Coordinate вместо r e a l# r e a l везде, где это необходимо, например: Coordinate -* Coordinate описывает точно то же, что и real # real —> real # real Заметим, что оператор type не создает нового типа данных, как это делает оператор data, а просто приписывает некоторое имя выражению типа данных. 2.6.1. Определение функций над типами данных Задав набор необходимых типов данных, можно перейти к описанию функций, работающих с ними. Для иллюстрации того, как создаются такие функции, мы рассмотрим четыре не­ большие программы: первые три демонстрируют различные спо­ собы обработки списков, а четвертая работает с более сложным типом данных — деревом. Пример 1. Соединение двух списков В этом примере мы создаем функцию Join с аргументами в виде двух списков, которые необходимо соединить. Например: Join("ET", "phone home")

38

Часть I. Глава 2

даст "ЕТ phone home" Как и в последующих примерах, мы начнем с описания типа функции Join. Важной особенностью Join является ее способ­ ность соединять списки объектов произвольного типа. С этой точки зрения Join является полиморфной функцией. Это озна­ чает, что она определяется параметрически — через переменные типа данных. Мы будем использовать определяемую в самом языке переменную alpha: dec Join : list( alpha ) # list( alpha ) -> list( alpha ); Оба списка, играющие роль аргументов, должны содержать объекты одного типа, поскольку иначе результирующий список будет содержать объекты различных типов, что запрещено. Вы­ полнение этого ограничения обеспечивается использованием во всем выражении одной и той же переменной типа alpha. Определение функции, оперирующей с типом данных, пре­ следует достаточно очевидную цель: вместо использования единственного правила для описания функции мы используем набор правил (иногда называемых правилами переписывания или уравнениями) по одному для каждой возможной «формы» аргумента, т. е. по одному для каждого конструктора в описа­ нии типа данных. Так, в функции Join аргументами являются списки, поэтому существуют два случая, которые надо рассмот­ реть: пустой список (конструктор nil) и непустой список (кон­ структор ::). Во введенном определении Join мы предлагаем считать вто­ рой аргумент как нечто цельное, не разделяемое на компо­ ненты. С другой стороны, структура первого аргумента может быть определена в более сложном виде. Если первый аргумент является пустым списком, а второй — некоторым списком L, то их соединение, очевидно, даст в результате L: ------- - Join( nil, L ) < = L ; Далее, если первый список имеет вид х\:у, то соединение его с L происходит путем присоединения у к L, а затем х к началу образованного списка. Опишем это правило следующим об­ разом: --------Join(x::y, L ) < = х :: Join( у, L ); Это определение является полным, так как мы рассмотрели все те формы, которые может принимать первый список. nil и х ::у в левых частях этих определений называются образцами. Образец выполняет две задачи: во-первых, он опре­

Введение в функциональное программирование. Язык Норе

39

деляет вид, который должен иметь аргумент перед тем, как будет применено соответствующее правило, а во-вторых, он как бы разделяет аргумент на компоненты и дает им некоторые названия (кроме того случая, когда образец представляет про­ сто константу). Например, образец х ::у определяет непустой список, голова которого имеет имя х, а хвост — у. При необ­ ходимости х и у могут использоваться при применении правила переписывания для обращения к частям — голове и хвосту — списка. Далее приводится пример использования введенной выше функции Join: J o i n ( " E T " , "ph o ne h o m e " ) == Jo in ( ' E ' :: { ' T' :: nil ), "phone home" ) — раскроем список " E T " ' E' .. ( " Г :: nil ) - > ' E ' :: J o i n ( ' T ' :: nil, "phone home") — может быть подставлено во в т о ­ рое правило для J o in с заменой: х = ' Е' и у = ' Т ' :: nil ->■ ' Е ' :: ( ' Т ' :: Joi n( nil, "phone hom e" ) ) — подставим ' T ' : : n i l с заменой х = = ' Т ' и у = nil ' Е ' :: ( 'Т ':: "phone home" ) — применим первое правило д л я Jo in - » ' Е ' : : " Т phone home" —'применим оператор :: - > " Е Т phone home" — применим оператор ::

Благодаря полиморфизму функции Join она может быть использована для объединения списков объектов и других ти­ пов, например Join([true, true, false, true], [false false]) даст следующий список булевых величин: [true, true; false, true, false, false] Здесь необходимо отметить то, что подобное соединение спи­ сков не изменяет содержимого их аргументов. Join создает полностью новый список, образованный из двух исходных, точно таким же образом, как выражение x ::L дает новый список с головой х и хвостом L, причем сами х и L не изменяются. В этом еще раз проявляется свойство функциональности, опи­ санной выше. Очевидно, что для успешного сопоставления с образцом необходимо отсутствие какой-либо двусмысленности в вопросе выбора правила, применимого для данного аргумента или на­ бора аргументов.- Простейшим способом избежать подобной двусмысленности является запрещение перекрывающихся левых частей, таких, как f( nil) < = . . . f ( x ) < = ...

40

Часть I. Глава 2

(если аргументом f является nil, то оба правила эквивалентны). Однако Норе допускает наличие перекрывающихся правил, если они однозначны. Приведенное выше определение является до­ пустимым, поскольку величина nil (конструктор) является бо­ лее точной, нежели х (переменная). Все примеры в этой книге, за исключением нескольких тривиальных функций в части III, будут использовать неперекрывающиеся образцы, так что по­ добные определения не возникнут. Мы, однако, вернемся еще раз к вопросу о перекрывающихся образцах в гл. 8, когда бу­ дем рассматривать сопоставление с образцом как важную часть процесса трансляции исходных программ в промежуточную форму. Функция Join является очень полезной и широко исполь­ зуемой функцией, в связи с чем она определяется в самом языке Норе. Она называется append и является инфиксным оператором с проритетом б^Гаким образом, записи "Е Т "( ) "phone home" и Join("ET", "phone home") являются эквивалентными,--------Пример 2. Инвертирование списка Рассмотрим простую функцию Rev, выраженную в терминах append, инвертирующую список произвольно типизированных объектов. Главная задача упражнения — как следует из назва­ ния — объяснить технику накапливания параметров, в которой вспомогательные параметры добавляются к функции для на­ копления результата. Эта техника довольно проста, но сильно влияет на время выполнения функциональной программы. Требования к функции Rev заключаются в том, что выра­ жение Rev([e,, е2........ еп_ь еп]) преобразуется в [бп, en_i,~ . . . , е2, e j

Отметим, что типы аргумента и результата функции, идентич­ ны, следовательно, справедливо определение dec Rev : list( alpha) -»■list( alpha);

Введение в функциональное программирование. Язык Норе

41

Поскольку аргументом функции является список, следует рассмотреть два правила: первое — если список пуст, то ре­ зультатом будет nil, поскольку инвертирование пустого списка порождает пустой список; второе — если' список имеет вид х :: L, инвертировать его следует путем инвертирования L и до­ бавления списка [х] в конец результирующего списка. Следовательно: --------Rev(nil) ' < == n il; --------Rev( х :: L ) < = Rev( L ) ( )[x]; Следует отметить, что [x] формирует единичный список, содержащий только х; это необходимо выполнить до примене­ ния операции < >. Хотя определение и корректно, его можно сделать более эффективным путем исключения вызовов Rev2([ 2, 3], [1 ]) Rev2( [ 3 ], [2, 1]) Rev2( nil, [ 3, 2, 1 ]) ~ 4 3 , 2, 1] Мы видим, что накапливающий список строится таким обра­ зом, что добавление нового элемента к результирующему списку включает только операцию :: и совсем не использует вызов . Благодаря этому количество вызовов :: теперь имеет .линейную

42

Часть I. Глава 2

зависимость от длины списка аргументов. В данном при­ мере функция Rev2 называется авторекурсивной функцией, по­ скольку результат функции в небазовом случае определяется лишь результатом вызова самой функции (с более простым «аргументом»). В следующем примере также показаны авторекурсивные функции (авторекурсивные вызовы подчеркнуты): -------- f(x) < = if х = 0 then 0 else !(х —- 1 ) ; --------g( х ) < = let а = = х — 2 in if P (a ) then a else g( a ) ; В гл. 18 увидим, каким образом накапливающие параметры могут быть использованы для «устранения рекурсии», т. е. пре­ образования, которое автоматически преобразует некоторую функцию в авторекурсивную форму или же, что эквивалентно, в циклы императивного языка, выполнение которых более эф­ фективно на обычных компьютерах. Пример 3. Разделение предложения на лексемы В этом примере мы рассмотрим функцию разделения пред­ ложения на составляющие его слова. Предположим, что слова разделены одним или более пробелами (обозначенными симво­ лами "), хотя на практике могу г встречаться и другие разде­ лители, например символ «конец строки» (обозначаемый в Hope crif) или знаки препинания, такие, как ‘, и т. д. На­ пример, для SplitUp("The following words") необходимо построить список слов ["The", "following", "words"] Как всегда, вначале определим тип функции, которою будем использовать. Для облегчения чтения программы будем также использовать оператор type для определения слов и предложе­ ний. Все они являются списками символов, следовательно, type word ===== list( c h a r); type sentence = — list( char); dec SplitUp : sentence->list(w ord); Разделение предложения можно проводить последовательным извлечением из него первого слова и добавлением его к резуль­ тату разделения, проделывая эту операцию до тех пор, пока

Введение в функциональное программирование. Язык Норе

43

в предложении не останется больше слов. При этом предпола­ гается, что для удобства вводится дополнительная функция, которая возвращает первое слово этого предложения и остав­ шуюся часть. Назовем эту функцию NextWord и объявим ее следующим образом: dec NextWord : sentence -> word # sentence ; Для ее определения рассмотрим возможные формы входного предложения. Базовым случаем является пустое предложение, при этом и слово, и остаток предложения должны быть пусты: NextWord(nil) < = ( nil, nil); Если же предложение непусто, оно будет иметь либо ограни­ читель в начале предложения (т. е. символ «пробел»), либо какой-нибудь другой символ. В первом случае следует дойти до конца следующего слова, и тогда результатом функции бу­ дет nil и остаток предложения: --------NextWord( N ext:: Rest) -let (R, S) = = NextWord("he trouble with Tribbles") in ( T : : R , S) —> ('T ':: "he", "trouble with Tribbles") = ("The", "trouble with Tribbles")

44

Часть I. Глава 2

Аналогично можно использовать функцию NextWord для опре­ деления функции SplitUp: --------SplitUp( n il) < = n il; --------SplitUp( N ext:: Rest) < = it Next = ' ' then SplitUp(Rest) else FirstWord :: SplitUp(RestOfSentence) where (FirstWord, RestOfSentence) = = NextWord( N ext:: R est); Ради разнообразия для декомпозиции образованного кортежа мы использовали where вместо let. Проверка на " относится к конструкции первоначального предложения. Заметим, что в определении SplitUp если следующий символ в предложении не является пробелом, то аргумент функции NexWord является тем же выражением, что и аргумент самой SplitUp. Для получения результата (и написания программы) это довольно неэкономно, поскольку сопоставление с образцом декомпозирует входные данные, а тело программы опять соеди­ няет их. К счастью, язык Норе позволяет дать имя всему об­ разцу аргумента, так что подобной неприятности можно избе­ жать: -------- SplitUp( Thelnput & N ext:: Rest) < = where (FirstWord, RestOfSentence) = — NextWord( Thelnput); Запись эта звучит так: "Thelnput является аргументом и дол­ жен быть непустым списком; начало его именуется Next, а ко­ нец— Rest”. Однако иногда мы вовсе не заинтересованы в том, чтобы присваивать имена образцам аргумента, например в случае, когда интерес представляет только форма аргумента, а не его компоненты. Хорошим примером тому является функция IsEmpty, которая выдает true, если заданный аргумент является nil, и false в противном случае: dec IsEmpty : list( alpha) -> tru v al; -------- IsEmpty( n il) < = tru e ; -------- IsEmpty(x:: 1) < = f a ls e ; Во втором уравнении не требуется, чтобы х или I были ис­ пользованы в теле функции. Для того чтобы было проще при­ сваивать имена началу и концу списка, можно использовать

Введение в функциональное программирование. Язык Норе

45

оперативный символ подчеркивания, означающий ’’безразлично” -------- IsEmpty( n il) < = tru e ; --------IsEmpty( — :: — ) < = false ; & и _ могут использоваться в качестве одного из символов об­ разца для присваивания (или неприсваивания) имен компонен­ там аргумента. Например, все нижеприведенные записи могут иметь место: х :: ( у & — —) а & (х :: 1):: — — :: ( ( х & ( а :: Ь )):: — ) Помимо этого в образцах можно использовать любые константы базовых величин, а в квалифицированных выражениях — про­ извольные образцы. Например, возможны такие записи: 'а ' 'P attern' [true, false, х, у] или выражение, использующее let или where: let ( N ext:: Rest) = = SplitUp("Big Ben") in Rest 3 ::P where [u, P & ( y ::z ) , [ v ] ] = — f(x) LabelName where ( ' L ' L a b e l N a m e ) ) = = Label Пример 4. Treesort — сортировка с использованием дерева Treesort — это программа, сортирующая список чисел в по­ рядке возрастания путем преобразования начального списка чисел в упорядоченное бинарное дерево и затем обратного преобразования этого дерева в (отсортированный) список. При­ ведем несколько примеров бинарных деревьев:

а

д

(а) — пустое дерево;

е

г

46

Часть I. Глава 2

(б) -— дерево с единственным элементом, который называется листом (на рисунке элемент подчеркивается) и имеет тип num; (в) и (г)— примеры многоуровневых деревьев, построенных из внутренних вершин, вершин-листьев и пустых деревьев. Каждая внутренняя вершина содержит число и два поддерева. Упорядоченное бинарное дерево — это такое дерево, в котором для каждой внутренней вершины все элементы левого подде­ рева меньше или равны значению вершины, а все элементы правого поддерева больше значения вершины. Приведенные выше примеры являются упорядоченными бинарными деревья­ ми. «Разглаживание» дерева состоит в таком преобразовании, при котором элементы дерева преобразуются в элементы линей­ ного списка. В качестве иллюстрации проделаем подобную опе­ рацию над деревом (г) и в результате получим список [1, 4, 8, 14, 15, 20]. Для того чтобы написать программу Treesort на языке Норе, рассмотрим поставленную задачу в полном объеме. Treesort бе­ рет неупорядоченный список чисел и образует упорядоченный, например: [

5, 2, 1. 9 ] ------------------------------------- ► [ 1, 2, 5, 9 ] Т reesort

Однако при этом сначала строится промежуточное упорядо­ ченное бинарное дерево, а уж затем оно преобразуется в упо­ рядоченный список (разглаживается): [ 5, 2 , 1, 9 ] — ► — [ 1, 2 , 5 , 9 ] \

T reeso rt

/

F la tte n

M akeTree

5

2

1

9

-

Подобное описание несет всю необходимую информацию о струк­ туре программы Treesort. Сначала, исходя из определения де­ рева, напишем определение данных на языке Норе data tree = = empty + + Ieaf(num) + + node( tr e e # n u m # tree);

Введение в функциональное программирование. Язык Норе

47

означающее, что дерево либо пусто, либо является листом, со­ держащий число, либо вершиной, содержащей число и два поддерева'. Диаграмма, приведенная выше, дает информацию не только о типах главных функций, необходимых для сортировки, но также и само определение Treesort: dec Treesort : list( num) -> list( num ); dec MakeTree: list( num ) -->• tree ; dec Flatten : tree -» list( num ); ------ Treesort( UnsortedList) < = Flatten( MakeTree( UnsortedList)); Все, что от нас теперь требуется, — это определить функции MakeTree и Flatten. Начнем с MakeTree. Аргументом функции MakeTree является список чисел; для определения этой функции следует рассмотреть два случая: один для пустого (nil) списка, другой для непустых списков. «Базовый» случай прост: --------MakeTree( n il) < = empty ; Если входной список не пуст и головой его является п, а хво­ стом rest, то требуемое дерево может быть построено путем образования дерева из остатка входных данных и последующей упорядоченной вставкой п в результирующее дерево. Для удоб­ ства определим вспомогательную функцию Insert, вставляющую число в дерево таким образом, чтобы была сохранена упорядо­ ченность. Предлагается следующее объявление типа функции Insert в соответствии с тем, выполнение какой операции от нее требуется: dec In se rt: num # tree

tre e ;

Теперь можно дополнить определение функции MakeTree: --------MakeTree( n :: re s t) < = Insert( n, MakeTree( re s t)) ; При определении Insert не следует забывать, что эта функ­ ция определена на дереве, следовательно, должно быть три пра­ вила, по одному на каждую из возможных конструкций дерева. Вставка числа в пустое дерево означает построение новой вер­ шины-листа: --------Insert(n, empty ) < = leaf(n); Если дерево уже является листом, то формируем новую вер­ шину. Однако следует соблюдать правильную упорядоченность

48

Часть I. Глава 2

нового дерева: -------- Insert(n, Oldleaf & leaf(m )) < = if n = < m then node(emply, n, Oldleaf) else node(Oldleaf, n, empty); Если же дерево уже является вершиной (т. е.! используется конструкция node), то новый элемент включается/либо в левое, либо в правое поддерево в зависимости от значения величины вершины: --------Insert(n, node(left, value, right)) < = if n = < value then node( Insert(n, left), value, right) else node( left, value, Insert(n, rig h t)); Приведем пример работы функции Insert: Insert(5, node(node( leaf( 1), 3, leaf(4)), 7, leaf(9))) -»-node( Insert(5, node( leaf( 1), 3, leaf(4)), 7, leaf(9))) ->node(node(leaf( 1), 3, Insert(5, leaf(4))), 7, leaf(9)) —> node( node( leaf( l ), 3, node(leaf( 4), 5, empty), 7, leaf(9))) Теперь, выяснив, каким образом работает функция Insert, можно рассмотреть и пример применения MakeTree: МакеТгее([7, 5, 2]) -»Insert(7, МакеТгее([5, 2 ]) ) —*Insert(7, Insert(5, M akeTree([2]))) -> Insert(7, Insert(5, Insert(2, M akeTree(nil)))) —>-Insert(7, Insert(5, Insert(2, em pty))) -> Insert(7, Insert( 5, leaf(2))) Insert( 7, node(leaf(2), 5, empty)) -> node(leaf(2), 5, Insert(7, empty)) node( leaf( 2), 5, leaf(7)) Для окончательного завершения программы осталось лишь определить функцию Flatten (разглаживание). Поскольку функ­ ция Flatten имеет дело с деревьями, следует опять рассмотреть три случая. Если дерево пусто, то результат принимает значение nil: ■ ----- Flatten( em pty) < = n il; В случае когда дерево состоит из листа, результатом будет яв­ ляться список из одного элемента: ■ -----Flatten( leaf( n )) < = [n];

Введение в функциональное программирование. Язык Норе

49

Если же' дерево является вершиной, то, поскольку оно упорядо­ чено, известно, что все элементы левого поддерева меньше значе­ ния величины вершины, а все элементы правого поддерева боль­ ше или рарны ей. Теперь представим это дерево в виде упоря­ доченного списка путем рекурсивного разглаживания левого и правого поддеревьев и затем объединения результирующих спис­ ков с одновременной вставкой между ними значения вершины: --------Flatten^ node( left, value, right ))< = = Flatten( left) ( } ( value :: Flatten( rig h t)); Этим завершается определение функции Treesort,/но к ней мы еще вернемся в следующей главе, где дадим ее обобщенный вид для сортировки объектов произвольного типа. Приведенные четыре примера должны продемонстрировать особенности одного из стилей программирования на базе функ­ циональных языков, в данном случае— Норе, названного систе­ матической дефиницией функций, определенных на типах дан­ ных. Структура задачи моментально подсказывает структуру решения, а структура типа данных — структуру каждой функ­ ции. Однако на базе функциональных языков существуют и дру­ гие стили программирования. Наиболее важные из них вклю­ чают в себя использования функций высшего порядка и «лени­ вого» вычисления. Эти особенности языка Норе будут рассмот­ рены в последних двух главах. 2.7. Доказательства по индукции Только что был приведен пример, показывающий, каким образом пишутся рекурсивные программы для не очень сложной задачи, причем программы обрабатывают смешанные типы дан­ ных простым написанием одного уравнения для каждой кон­ струкции аргумента типа данных. Завершим изложение этой главы рассмотрением довольно мощной техники доказательств, имеющей место в функциональных программах и основанной на принципе математической индукции. Такая техника доказа­ тельств называется структурной индукцией, поскольку процесс индукции проводится на синтаксической структуре функцио­ нального выражения. Поясним эту концепцию в терминах простой индукции над целыми числами, которая, вероятно, давно знакома большин­ ству читателей. Этот принцип утверждает, что можно устано­ вить некоторое именуемое предположением индукции свойство Р(п) для всех неотрицательных чисел п, если мы сможем до­ казать справедливость Р(0) и, допустив существование P(k), 4



1473

50

Часть I. Глава 2

доказать P{k-\- 1) для всех целых чисел k ^ O . 3rd и назы­ вается принципом математической индукции. Простой иллю­ страцией этого примера может служить доказательство того, что сумма S(n) от первых п чисел есть л(п + 1)/2. В данном случае для Р(п) мы имеем утверждение 'S(n) =1 п(п + 1)/2'. Базовый случай имеет место тогда, когда п = 0,/ и не трудно убедиться, что действительно 5(0) = 0, следовательно, выраже­ ние Р(0) справедливо. Теперь, допустив существование P(k), т. е. что 'S(k) — k(k -j- 1 )/2 '— истинны, имцем S (fe+ 1 ) = ■^=(k+ 1) + S ( k ) ( k + 1) + k ( k + l ) / 2 = ( k + l ) ( k i - 2 ) / 2 , т. e. P (& + 1 ) истинно. Следовательно, использовав правило матема­ тической индукции, мы доказали, что Р{п) справедливо для любых п. Имеет место и аналогичный принцип индукции, утверждаю­ щий, что Р(п) истинно для всех неотрицательных целых п, если Р(0) истинно и можно доказать P(k) для любого & > 0, пред­ положив истинность P(j), j num ; --------sum(nil-) < = 0; --------sum( x :: 1) < = x + sum( 1); Определить эквивалентную функцию, использующую накапли­ вающий параметр для подсчета суммы. Уменьшилась ли при этом алгоритмическая сложность функции?

Введение в функциональное программирование. Язык Норе

53

2.3. Рассмотрим некий «мешок» *>, который по сути своей подо­ бен множеству, за исключением лишь того, что данный элемент может встречаться в нем более одного раза. Представим мешок в виде списка пар, где каждая пара состоит из элемента и числа, определяющего, сколько раз данный элемент встречается в мешке. Следует написать полиморфное определение типа для подобного мешка совместно с функциями: add — для добавле­ ния элемента в данный мешок, remove — й качестве результата, выдающего произвольный элемент мешка совместно с самим мешком, из которого данный элемент уже удален; lsempty — возвращающая значение true, если данный мешок пуст. В тер­ минах add определить функцию union для мешков. 2.4. Написать объявление и определение функции search, про­ сматривающей часть текста в поисках заданной строки (когда строка встречается впервые) и возвращающей значение либо О, если строка в тексте отсутствует, либо п, если строка встре­ чается в тексте, начиная с позиции n-го символа. Предпола­ гается, что текст является списком символов. Например, для заданного текста ’’Hence home you idle creatures” и искомой строки ”eat” функция выдает значение 23. 2.5. Сортировка вставкой является алгоритмом сортировки, ко­ торый из входного списка порождает отсортированный путем повторной вставки элементов исходного списка на правильные позиции в частично отсортированный список. Для того чтобы показать пример работы алгоритма, возьмем исходный список вида [4, 1, 5, 3 ]: Исходный список

I1

Отсортированный (частично) список

[ 1, 4, 5 ] [ 1, 3, 4, 5 ]

Р езу л ь т ат = [ 1, 3, 4, 5 ].

Реализовать сортировку вставкой как функцию языка Норе. 2.6. Направленный граф есть сеть, состоящая из вершин, со­ единенных однонаправленными дугами. Каким образом можно задать направленый граф в языке Норе? Определить функцию pathlength, исходными даными которой являются граф и две его вершины, а выходными либо мини­ мальное число дуг, которые необходимо пройти для того, чтобы попасть из одной вершины в другую, либо 0, если между этими вершинами не существует пути. 11 Д а л е е термин «мешок» (bag) даем без кавычек. — Прим. ред.

54

Часть I. Глава 2

2.7. Предположим, что в Норе не существует встроенного типа truval. Каким образом данные этого типа можно представить в виде типов данных, определяемых пользователем? Для вы­ бранного представления написать объявления и определения инфиксных логических функций: and, or и not. 2.8. а. Требуется создать базу данных штата сотрудников уни­ верситета, где для административных и организационных целей на каждого сотрудника были бы заведены определенные дан­ ные. Есть два типа сотрудников: преподаватели и обслужи­ вающий персонал. Для обоих типов необходимо хранить сле­ дующие записи: фамилия, пол, дата рождения, дата зачисления в университет и домашний адрес. Каждый сотрудник из штата преподавателей принимает участие в одной 'из трех кафедраль­ ных секций: системы, математическое обеспечение, теория. Их записи в базе данных помимо уже приведенной информации включают также секцию преподавателя и список курсов, кото­ рые он ведет в настоящее время (каждый курс имеет свой соб­ ственный номер). Каждый сотрудник из обслуживающего персонала принадлежит либо секретарскому штату, либо обслу­ живает компьютеры, либо занимается каким-то материальнотехническим обеспечением. Их записи в базе данных кроме уже заданной информации включают и эту классификацию. Пред­ ложить возможный вариант этой базы данных на языке Норе, написать требуемые определения type и data. б. Определить Hope-функцию для каждого следующего случая: 1) подсчитать общее количество преподавателей и обслуживаю­ щего персонала в базе данных; 2) получить фамилию сотрудника, преподающего заданный курс; 3) вычислить отношение количества тех, кто обслуживает ком­ пьютеры, к числу тех, кто занимается материально-техническим обеспечением. 2.9. С помощью структурной индукции докажите, что сложение, определенное на множестве натуральных чисел следующими соотношениями: data nat = = zero -|—\~ succ( n a t); dec add ; n a t # n a t -> nat; —------ add(n, z e r o ) < = n ; ------— add(n, succ( m )) < — succ( add( n, m )) ; ассоциативно и коммутативно. (Подсказка: для выполнения коммутативности надо показать, что add ( n, succ( m )) = == add ( succ ( n ), m ) для любых n, m типа nat.)

Глава 3 ФУНКЦИИ ВЫСШЕГО ПОРЯДКА

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

56

Часть I. Глава 3

3.1. Примеры рекурсии Рассмотрим следующие функции языка Норе: dec IncList: list( num ) ->■ list( num ); --------IncList( n il) < = n il; -----— IncList( x :: 1) < = ( x + 1):: IncList( 1); dec MakeStrings : list( char) -> list( list( char)); --------MakeStrings( n il) < = n il; --------MakeStrings( с :: 1) < = [ c] :: MakeStrings( 1); Хотя эти функции и неодинаковы, поскольку выполняют раз­ личные операции над списками разных типов, они, однако, сходны в том, что над каждым элементом списка выполняется только одна операция. Первая функция увеличивает на единицу каждый элемент в списке чисел, а вторая отображает каждый элемент списка символов в список из одного элемента. Говорят, что «тип рекурсии» в обоих случаях одинаков. В функциональном языке этот тип рекурсии можно выра­ зить, используя функции высшего порядка. Функция высшего порядка — это функция, которая может использовать в качестве аргумента другую функцию либо результатом ее выполнения является некая функция. Если быть более точным, то функция первого порядка имеет тип А-»-В, где в выражениях А и В нет стрелок. Таким образом, можно сказать, что функция высшего порядка — это любая функция, не являющаяся функцией пер­ вого порядка. В обоих вышеприведенных примерах функция применяется к каждому элементу заданного списка. В IncList она имеет следующее определяющее уравнение: --------Inc(n) < = n + 1 ; в то время как для MakeStrings уравнение функции имеет вид --------Listify( с ) < = [с]; (с соответствующими объявлениями типа). Можно выявить об­ щую структуру таких функций, как MakeStrings и IncList, определив функцию высшего порядка, которая, используя функ­ ции типа Listify или Inc в качестве аргумента, применяет их к каждому элементу заданного списка, выступающему в роли второго аргумента. Подобная функция высшего порядка ши­ роко используется и часто называется шар для отражения того факта, что она отображает каждый элемент списка. Поскольку мы не знаем наперед тип применяемой функции или тип обра­ батываемых элементов списка, то шар должна быть объявлена

Функции высшего порядка

57

полиморфной функцией. Полное ее определение выглядит так: dec шар : ( alpha -> beta ) # list( alpha ) -*■ list( b eta); --------map(f, n il) < = n il; --------map(f, x:: I) < = f ( x ) ::m a p ( f , 1); Теперь, используя шар и вспомогательные функции Inc и Listify, введенные ранее, можно выразить IncList и MakeString: IncList( L ) ^ map( Inc, L) MakeString ( L ) s map( Listify, L) Удобство того, что для отображения списка предварительно были определены функции Inc и Listify, очевидно. Однако яв­ ное определение подобных функций может быть довольно не­ удобно, в особенности если отображающая функция проста, как, например, обе функции в нашем случае, и больше,нигде в программе не используется — ведь нам придется и объявлять эти функции, и определять их правила работы. К счастью, язык Норе дает возможность записывать выражение (именуемое лямбда-выражением), значением которого является функция, позволяющая непосредственно выражать Inc и Listify. Напри­ мер, Inc будет описана так: lambda х = > х + 1 (Значение заранее определенного слова lambda будет конкре­ тизировано в гл. 6.) Запись станет понятна, если читать lambda как «функция от...», а =>----как «..., которая выдает резуль­ тат...». Таким образом, в общем виде функция f, определяемая уравнением: --------f( х) < = Е ; где f не встречается в Е, эквивалентно' может быть представ­ лена как лямбда-выражение: lambda х = > Е Покажем теперь применение функции IncList, используя шар и лямбда-выражение: IncList( L ) s -г map( lambda х = > х + 1, L ) и, аналогично, для MakeStr'ngs: M akeStrings(L) map( lambda с = > [с], L) Тело лямбда-выражения может быть произвольным выраже­ нием, однако не следует забывать, что выражение подобного типа, не может быть рекурсивным, поскольку не существует

58

Часть I. Глава 3

такого связанного с функцией имени, на которое можно было бы ссылаться. Этого неудобства можно избежать, используя вне lambda-утверждения операторы let или where. Let и where вво­ дят имя, которое, если это необходимо, может быть использо­ вано внутри выражения, определяющего это имя. Так, функ­ ция f, заданная в виде fA < = . . . f

;

где А — произвольный образец, может быть представлена с ис­ пользованием let или where: let f = = lambda A = > . . . f . . . in Ef или Ef where f = = lambda A = > . . . f . . . Здесь Ef — некоторое выражение, содержащее f. Например: let f = = lambda x = > if x = 0 then 0 else x + f( x — 1) in f(3) подсчитывает сумму первых трех целых чисел; т. е. 3 + 2 + 1 + + 0 = 6. Выражения let или where, задающие рекурсивную функцию, часто называются рекурсивными let- или рекурсив­ ными where-выражениями. В некоторых языках рекурсивные 1е^выражения отличаются от нерекурсивных использованием отдельного ключевого слова, такого, как letrec или whererec. Пример такого языка будет дан в гл. 5. В языке Норе лямбда-выражение может также содержать включенные правила (т. е. включенные образцы), разделенные символом |. Например, функция IsEmpty, определенная как --------IsEm pty(nil) < = tru e ; --------IsEmpty( — ) < = false ; мод^ет быть записана через лямбда-выражение: lambda nil —> true |

— = > false

Другой пример — выражение map( lambda nil = > " ? " | h :: — = > "Head is"( )[h], ["Monsters", nil, nil, "from", nil, "the", "Id"]) порождает список строк ["Head is M", "?", "?", "Head is f", "?", "Head is t", "Head is 1"]

Функции высшего порядка

59

а выражение map(lambda с:: — = > с, SplitUp( "Time and relative dimension in space")) (где функция SplitUp определена в предыдущей главе) выдает строку "Tardis". Функция т а р имеет также свойство восстанавливать оригиналь­ ный список, в котором каждый элемент трансформируется в со­ ответствии с отображающей функцией. Однако иногда требует­ ся «уменьшить» список до какой-то иной величины, например числа, являющегося либо длиной списка, либо максимальной, величиной из списка. Подобный тип рекурсивной функции также можно описать в терминах функций высшего порядка: dec reduce : ( alpha # beta —►beta ) # beta # list( alpha ) —> beta ; --------reduce(f, b, n il) < = b ; --------reduce( f, b, x : : l ) < — f(x, reduce(f, b, 1)); Для того чтобы посмотреть, как работает эта функция, отметим, что выражение reduce(f, b, [еь е2, .. ., еп]) эквивалентно выражению f(eb f(e2, . . . f(e„, b) . . . )) где b — «базовая», т. е. полученная при редуцировании пустого списка величина. Большое число сложных операций обработки списков можно выразить с помощью функции reduce при соот­ ветствующей функции { и базовой величине Ь. Для наглядности обратимся к простому выражению, подсчитывающему сумму элементов списка чисел, обозначаемого буквой L: reduce(+ , О, L) (Следует отметить, что, хотя reduce допускает только префикс­ ные функции, + является инфиксной функцией. Однако если инфиксная функция встречается в виде параметра, она автома­ тически преобразуется в эквивалентную префиксную форму.) Рассмотрим пример работы reduce на последовательности вычислений для L = [l,3, 5] (префиксная функция + записы­

60

Часть I. Глава 3

вается как < + )): reduce( + , 0, [1, 3, 5]) = reduce(+, 0, 1 :: (3 :: (5 :: n il))) -* ( + )(1, reduce(+, 0, 3 :: (5 :: n il))) -* ( + >( 1> ( + )( 3, reduce(+ , 0, 5:: n il))) ->< + ) ( ! , (5, reduce(+, 0, n il)))) -»< + > (l, sum + ( if name = "Spock" then 1 else 0), 0, L) (3) Поиск в списке: результат есть true, если el принадле­ жит списку, false — в противном случае: reduce(lambda (next, is th e r e ) = > if isthere then true else( next = e l), false, L )’ (4) Функция MakeTree из примера Treesort, показанная в предыдущей главе. Из списка чисел L порождается дерево: reduce( Insert, empty, L) (5) Функция индентичности списков: reduce(::, nil, L) Отметим, что в последнем примере конструктор : : выступает в роли аргумента reduce. Это довольно удобно, поскольку кон­ структор ведет себя точно так же, как любая другая функция, за исключением того, что он не имеет никаких правил.

Функции высшего порядка

61

3.2. Связывание Важно не забывать, что величины переменных в лямбда-вы­ ражении устанавливаются тогда, когда выражение определяет­ ся, а не тогда, когда оно используется. Например, если пишется, что let х = = 1 in (let f = = lambda y = > x + y in (let x = = 2 in f(x ))) то в записи x + y переменная x имеет значение, равное 1, т. е. значение переменной х в точке определения функции f. Это на­ зывается статическим связыванием. Есть языки (например, не­ которые диалекты языка Лисп), пользующиеся динамическим связыванием, при котором определение переменных в лямбдавыражении происходит в процессе его использования. Если бы в Норе применялось динамическое связывание, то значение х при вычислении х + у в нашем примере было бы равно 2 (т. к. х равно 2 при использовании f). Первоначальное значение х в данном случае теряется. В общем случае динамическое связывание считается непри­ емлемым, поскольку значение функции может меняться в зави­ симости от контекста, в котором она используется. Это озна­ чает, что при вызове одной и той же функции можно получать различные ответы, что противоречит идее функциональности. Для устранения этой проблемы все современные функциональ­ ные языки используют статическое связывание. В гл. 9 мы еще вернемся к свойствам, вытекающим из связывания, 'и увидим, каким образом осуществляется статическое связывание в ин­ терпретаторе функционального языка. 3.3. Другие примеры рекурсии Как уже было показано, функции высшего порядка направ­ лены на создание рекурсивной структуры, общей для многих функций, имеющих дело со списками. Тем не менее можно опре­ делить такие типы данных произвольной сложности, для кото­ рых функции типа шар и reduce не являются функциями «аб­ страктной рекурсии». Однако нет никаких причин, запрещаю­ щих связывать различные функции высшего порядка с этими типами данных для образования структур многих рекурсивных функций, определенных на них. В качестве простого примера рассмотрим, каким образом можно описать уже известную нам функцию обработки дерева, используя эквиваленты шар и re­ duce, определенные для списков. Подобный подход может быть

62

Часть I. Глава 3

с успехом использован и для других типов, определенных поль­ зователем. Для общности предположим наличие полиморфного опреде­ ления деревьев, в котором объекты, включенные в дерево, мо­ гут быть произвольного типа alpha. Также для простоты усло­ вимся рассматривать лишь деревья, построенные из пустых деревьев и внутренних вершин (иначе говоря, опустим конструк­ тор leaf, представленный в программе Treesort предыдущей гла­ вы). Итак, определение данных будет выглядеть следующим образом data tree( alpha) = = empty + + node( tree( alpha ) # alpha # # tree( alpha )); Можно создать обобщенную функцию, которая бы применяла данную функцию к каждому элементу (типа alpha) в дереве, т е. функцию высшего порядка МарТгее, являющуюся очевид­ ным расширением т а р , определенным для списков: dec МарТгее ( alpha -> beta ) # tree( alpha ) —*■tree( beta ); --------MapTree(f, empty) < = empty ; --------MapTree(f, node(left, value, right)) < = node( MapTree( f, left), f( value), MapTree(f, rig h t)); Теперь, например, мы можем увеличить на единицу каждый элемент в дереве чисел Т: МарТгее (lambda х = > х + 1, Т) Эквивалент функции reduce для списков более интересен, по­ скольку теперь уже известно несколько способов рассечения де­ рева. Например, имеется возможность независимо друг от друга редуцировать оба поддерева и затем применить функцию к ре­ зультатам: dec TreeReduce : ( alpha # beta # beta -> beta ) =#=beta # # tree( alpha ) —> beta ; --------TreeReduce( f, b, empty ) < = b ; --------TreeReduce( f, b, node( left, value, right ) ) < = Rvalue, TreeReduce(f, b, left), TreeReduce(f, b, rig h t)); Так что, например: TreeReduce( lambda ( v, г 1, r2 ) = > rl ( ) ( v :: r2 ), nil, T) приводит к разглаживанию. дерева Т способом, описанным в гл. 2, и TreeReduce(lambda (v, rl, r 2 ) —> m a x (v , m ax(rl, r2)), О, T );

Функции высшего порядка

63

вычисляет наибольшее число в дереве неотрицательных целых чисел. Мы можем, однако, определить и такую форму reduce, которая, используя результат редуцирования правого поддерева в качестве «базового» случая, редуцирует левое поддерево: dec TreeReduce3 : (-alpha # beta — beta ) # beta # tree( alpha ) -*■ —>beta ; --------TreeReduce3( f, b, empty ) < = b ; --------TreeReduce3( f, b, node(left, value, right ) ) < = TreeReduce3( f, f( value, TreeReduce3( f, b, right)), left); Отметим, что теперь требуется, чтобы параметр функции f был функцией от двух аргументов Используя такую форму reduce, можно переписать представленное ранее выражение для вычис­ ления максимального числа в дереве Т: TreeReduce2( max, О, Т) Дадим еще один вариант, в котором вызов f помещен в другое место программы: dec ТreeReduce2 : ( alpha # beta beta) # beta # tree( alpha ) -> -►beta ; --------TreeReduce2( f, b, empty) < = b ; --------TreeReduce2( f, b, node( left, value, right)) < = Rvalue, TreeReduce2(f, TreeReduce2(f, b, right), le ft)); Используя такое определение, мы можем осуществить разгла­ живание дерева без использования append: TreeReduce3(::, nil, Т) Проницательный читатель может заметить, что это выражение эффективно разглаживает дерево при использовании «базовой» величины в качестве накапливающего параметра. Аналогично тому, как использование накапливающего параметра уменьшает сложность функции Reverse, приведенной в гл. 2, от квадратич­ ной до линейной, также уменьшается и число вызовов : : от 0 (п 2) до О(п), где п — число элементов в дереве. Есть и другие варианты функции reduce, отражающие различные способы разбиения бинарного дерева. Итак, путем определения небольшого набора функций выс­ шего порядка для обработки каждого типа данных можно избе­ жать написания многих явных рекурсивных функций для этого типа, используемых вместо функций высшего порядка с соот­ ветствующими параметрами. В этом смысле данный способ срав­ ним с техникой полиморфизма: полиморфный тип данных дает возможность описывать структуры с подобной общей формой

64

Часть Г. Глава 3

в виде единственного определения; функции высшего порядка позволяют описывать рекурсивные функции с подобной общей структурой в виде единственной функции. 3.4. Пример применения Для иллюстрации использования функций высшего порядка на практике рассмотрим простую задачу текстовой обработки и покажем, каким образом подобные функции могут быть ис­ пользованы для представления решения задач в очень кратком и абстрактном виде. Проблема, которую предстоит рассмотреть, связана с образованием списка счетчиков слов из текстового файла. Предполагается, что текст состоит из некоторого количе­ ства слов и каждому слову в тексте сопоставлено целое число, соответствующее тому, сколько раз данное слово встречается в тексте. Результатом будет являться список пар вида (word, count) где count — это число встречающихся слов word в тексте. После завершения написания этой программы мы увидим, каким об­ разом Treesort, представленная в предыдущей главе, может быть расширена для сортировки подобных пар различным об­ разом путем трансформации ее в функцию высшего порядка. Итак, пусть исходный текст составляют предложения, раз­ деленные точками, а каждое предложение есть совокупность слов, разделенных одним или несколькими пробелами. Для про­ стоты определим слово как любую последовательность симво­ лов, исключая пробелы и точки. Например, преобразуем задан­ ный текст "Hi said Bill. Hi said Ben." в вид [("Hi", 2), ("said", 2), ("Bill", 1), ("Ben", 1)] Прибегнем к помощи функции, введенной в разд. 2.6.1, и к тем определениям функций шар и reduce, которые были даны ранее. 3.4.1. Подсчет слов Вначале необходимо разбить предложения на составляющие его слова. Однако нельзя сразу же применять функцию SplitUp, поскольку текст содержит точки, которые разделяют предложе­ ния и не могут быть обработаны с помощью SplitUp. Прежде чем применить к тексту функцию шар, следует заменить все

Функции высшего порядка

65

точки на пробелы (т. е. везде, где встречается символ «точка», надо поставить символ «пробел», а не удалять и то и другое, чтобы не возникла ситуация, когда последнее слово одного предложения слилось бы с первым словом следующего предло­ жения) : map(lambda с = > if с — '-' then ' ' else с, Text) где Text — это обрабатываемый текст. Для разделения текста после удаления точек можно применить функцию SplitUp: SplitUp(map(lambda c = > i f c = '- / then ' ' else c, Text)) Теперь можно приступать к построению требуемого списка пар (слово, число его употреблений в тексте). Итак, начнем со списка nil, в дальнейшем преобразуя список всякий раз, когда в тексте встречается новое слово. Например, если список содер­ жит пары [("the", 1), ("day", 1), ("of", 1)] а следующее слово во входном тексте — "the", то новый список принимает вид [("the", 2), ("day", 1), ("of", 1)] Если очередное слово в списке еще не содержится, то появ­ ляется новая пара с начальным значением 1. Например, если в указанном тексте после "the" идет слово "jackal", то список будет выглядеть так: [("the", 2), ("day", 1), ("of", 1), ("jackal", 1)] Определим отдельную функцию Update, выполняющую такую операцию преобразования списка, которая сделает окончатель­ ный вариант программы более удобным для чтения: type word = = list( ch ar); dec Update : word # list ( word # num ) -*• list ( word # num ); --------Update(w, n i l ) < = [ ( w , 1 )]; --------Update(w, ( Entry &( Word, Count)):: R est) < = if w = Word then (Word, Count + 1):: Rest else Entry :: Update(w, R est); Однако при таком варианте необходимо вызывать функцию Update для каждого слова, встречаемого в тексте. Очевидно, что этого неудобства можно избежать «уменьшением» списка слов во входном тексте, используя функцию Update: reduce( Update, nil, Wordlist) 5



1473

66

Часть I. Глава 3

Таким образом, строится выражение Update(wb Update(w2, Update(w3, . . . , Update( wn, n i l ) . . . ) ) ) где w, (1 ^ i ^ n) — слова-компоненты в Wordlist. Теперь, после того как все объединено, можно определить и окончательную функцию WCount, порождающую счетчик слов из текстового файла. Обратим внимание на использование пред­ варительно определенной функции fromfile, которая, получив имя файла в виде списка символов, возвращает содержимое этого файла также в виде списка символов: type filename = = list( char ); dec W Count: filename -> list( word # num ); --------WCount( name ) < = reduce( Update, nil, Wordlist) where Wordlist = = SplitUp( map( RemoveStop, fromfile(name))) where RemoveStop ===== lambda c = > i f c = ' ' then ' ' else c ; Для демонстрации работы программы предположим, что файл sample содержит текст the dog and the cat Выражение input (sample) выдает содержимое файла sample в виде списка символов; результатом применения функции RemoveStop к каждому элементу этого списка является анало­ гичный список с удаленными символами точек, находящихся в конце предложения; вызов функции SplitUp порождает спи­ сок слов: ["the", "dog", "and", "the", "cat"] В итоге, применение Update при базовом случае nil обраба­ тывает это выражение так: Update( "the", Update( "dog", Update( "and", Update("the", Update("cat", n il) )))) = [("cat", 1), ("the", 2), ("and", 1), ("dog", 1)] что и требовалось. 3.4.2. Классификация выходных данных Было бы замечательно, если, подсчитав сколько раз каждое слово встречается в исходном тексте, можно было бы провести сортировку, основанную либо на частоте встреч (наиболее ча­ сто встречающееся слово на первом месте), либо на текстовом

Функции высшего порядка

67

порядке слов. Для решения этой задачи можно использовать программу Treesort, представленную в гл. 2, но, к сожалению, она определена таким образом, что может быть применима лишь для целых чисел; в данном случае желательно сортировать списки пар. Для того чтобы использовать Treesort, необходимо модифицировать эту программу для работы с более общими ти­ пами объектов. Если обратиться к первоначальному варианту Treesort, то видно, что упорядочивание элементов в промежуточном дереве проводилось с помощью примитивного оператора = < . С его помощью функция Insert обеспечивает то, что все элементы в левом поддереве данной вершины меньше или равны элементам правого поддерева. Однако Treesort можно сделать более силь­ ной, если параметризировать ее этой упорядочивающей функ­ цией. Тогда Treesort станет не только функцией высшего по­ рядка, но также и полиморфной, поскольку с ее помощью можно будет сортировать объекты любого типа, если им присуще свой­ ство упорядоченности. Изменения, которые необходимо для этого провести, очевидны. Сначала следует сделать тип данных tree и соответствующие функции полиморфными, затем в виде параметра добавить упорядочивающую функцию к Treesort, MakeTree и Insert и, наконец, заменить ссылки на примитив = < в Insert на этот новый параметр-функцию. dec Тreesort; list( alpha) # Ordering -*• list( alpha); dec MakeTree : list( alpha ) # Ordering —►tree( alpha ); dec In se rt: alpha # tree( alpha) # Ordering —►tree( alpha); --------Treesort( UnsortedList, TestFun) < = Flatten( MakeTree( UnsortedList, TestFun)); — ----MakeTree(-List, f ) < = reduce(lambda (n, t ) = > Insert(n, t, f), empty, List); — ----Insert(n, empty, — ) < = l e a f ( n ) ; --------Insert(n, OldLeaf & leaf( m ), { )< = > if f( n, m ) then node(empty, n, OldLeaf) else node(OldLeaf, n, empty); -------- Insert(n, node(left, value, right), f ) < = if f(n , value) then node( Insert(n, left, f), value, right) else node(left, value, Insert(n, right, f ) ) ; (Новая упорядочивающая функция не повлияла на определение Flatten.) Функцию Treesort можно заставить вести себя так же, как и раньше, если употребить в ней первоначальную функцию

68

Часть I. Глава 3

упорядочивания, т. е. = < . Например, выражение вида Treesort( [5, 3, 4, 7, 2, 3], = < ) как и прежде, выдает отсортированный список [2, 3, 3, 4, 5, 7J. Однако эту функцию можно теперь использовать и для сорти­ ровки пар «слово — счетчик». Если потребуется отсортировать пары по частоте появления слов, то упорядочивающая функция будет основываться на сравнении счетчиков частоты. Вид этой функции таков: lambda((—, c l), ( —, с 2 ) ) = > (c l > = с 2 ) Если необходимо отсортировать пары в алфавитном порядке, то надо сравнивать строки слов, и в данном случае упорядочи­ вающая функция будет выглядеть так: lambda(( w 1, —), (w2, — ) ) = > StringLessEq(wl, w2) где StringLessEq — функция «меньше-или-равно», (оперирую­ щая со строками), которая определяется достаточно просто как рекурсивная функция, работающая со списками. Итак, например, если надо упорядочить по частоте уже из­ вестный список слов из текстового файла "sample", то Treesort( WCount( "sam ple"), OrderingFunction) where OrderingFunction = = lambda(( —, c l), ( —, c 2 ) ) = > (cl > = c 2 ) в результате чего будет получен требуемый упорядоченный список вида = [("the", 2), ("dog", 1), ("and", 1), ("cat", 1)] Надо отметить, что существует еще один подход к процессу сортировки результирующего списка, заключающийся в такой модификации функции Update, при которой промежуточные спи­ ски счетчиков слов располагались бы в необходимом порядке. Такую операцию можно осуществить хотя бы путем помещения пар (слово, число) в отсортированное дерево, которое затем разглаживается и принимает вид упорядоченного списка; при этом уменьшается сложность алгоритма. Здесь же, однако, была поставлена цель найти простое и четкое решение задачи путем разделения операций генерации и сортировки соответ­ ствующего списка слов. И уже затем, найдя такое решение, если это требуется, можно заняться оптимизацией программы — в идеальном случае это было бы сделано компилятором, что бу­ дет продемонстрировано в части III этой книги. Альтернативное

Функции высшего порядка

69

решение данной задачи, о котором тут было упомянуто, остав­ лено в качестве упражнения для читателя. Итак, закончено обсуждение, функций высшего порядка и приемов программирования этих функций. Следует отметить, что в Норе, как и в большинстве функциональных языков, не суще­ ствует ограничений на количество и сложность подобных функ­ ций. Однако в гл. 5 будет рассмотрен функциональный язык FP, в котором нет средств для определения пользователем функций высшего порядка. Вместо этого язык обеспечен небольшим на­ бором встроенных функций высшего порядка, которые рассмат­ риваются как программно-встроенные блоки типа let, where, условные выражения и др., которые в языке Норе обеспечивают построение программных блоков. Мы увидим также, каким образом многие рекурсивные про­ граммы могут быть переписаны в нерекурсивном виде, исполь­ зуя предварительно определенные функции высшего порядка. В гл. 5 будет описан язык Miranda, где осуществлен совер­ шенно иной подход к функциям высшего порядка. В Норе объ­ ект, значением которого является функция, явно порождается при помощи лямбда-выражения; в Miranda же нет механизма для явного определения лямбда-выражения, функциональные объекты в нем генерируются частичным применением суще­ ствующих функций. Для иллюстрации подобного подхода в гл. 5 будет приведено несколько примеров. Резюме • Функции могут рассматриваться как объекты первого класса. • Функции, которые в качестве параметров используют другие функции или же возвращают их как результат своей работы, называются функциями высшего порядка. • Функции высшего порядка используются для описания общих видов рекурсии. • Применение функций высшего порядка часто приводит к очень кратким и абстрактным программам. • Отдельные функции высшего порядка могут соответствовать всем типам данных. • Многие функциональные языки статически связывают пере­ менные в телах функций. • Многие функции, например обеспечивающие сортировку, можно обобщить, используя функции высшего порядка. Упражнения 3.1. Определите шар и в терминах функции reduce. 3.2. Предположим, что было выполнено несколько булевых тестов, результаты которых хранятся в виде списка булевых

70

Часть I. Глава 3

величин. Используя функцию reduce, напишите отдельное выра­ жение, которое: а) возвращает true, если по крайней мере один из тестов выполняется (т. е. результатом его является true), и false — если не выполняется ни один из них; б) возвращает true, если выполняются все тесты, false — если один из них не выполняется. 3 .3 . На языке Норе определите функцию одного параметра п, генерирующую список длины п, где f-й элемент в списке есть функция одного аргумента, которая при применении добав­ ляет i к заданному числу. 3 .4 . а. Определите compose-функцию высшего порядка, кото­ рая возвращает композицию двух заданных функций, являю­ щихся ее аргументами. б. Напишите выражение, которое определяет функцию Treesort в терминах compose, Fllatten и Maketree. в. Пусть задан список функций F = [fi,f2, . . . , fn] , каждая типа (alpha—valpha); используя функции compose и reduce, напишите на языке Норе выражение С такое, что С( F ) ( х ) = f]( f2( . . . ( fn( х )) . . . )) 3 .5 . Рассмотрим на языке Норе следующую инфиксную функцию; infix / : 6; dec/ : alpha -►( beta -* beta) # list( alpha) -»■( beta ) —►( b eta); --------f/n il < = lambda y = > y ; -------- f / x :: 1 < = lambda у = > f( x ) ( ( f / 1) ( у )); а) сравните эту функцию с функцией reduce для списков: б) определите функцию sum в терминах /, которая бы сум­ мировала элементы заданного списка чисел; в) определите функцию add имеющую тип dec add : num —>■( list( num ) -> list( num )); где add(n) возвращает функцию, ставящую п на поло­ женное ему место в любом списке чисел, упорядоченном по возрастанию; г) напишите Норе-выражение, используя / и add, сортирую­ щие список [4, 2, 7, 3] в порядке возрастания. 3.6. Укажите для следующих функций: --------g(h, n ) < = l e t х = = 5 in h(n + x ); -------- f( x ) < = g( lambda у = > x + У — 2, 6 ); что будет являться величиной выражения f( 1) npi^ а) статическом связывании, б) динамическом связывании.

Глава 4 ВИДЫ ВЫЧИСЛЕНИЙ

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

72

Часть I. Глава 4

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

Понятие строгости

Говорят, что функция, которая всегда требует значение од­ ного из своих аргументов, является строгой по отношению к этому аргументу. Например, к примитивной функции + нельзя обращаться, пока оба ее аргумента не будут вычислены до конкретного числа, т. е. она является строгой по отношению к обоим аргументам. Однако некоторые функции могут выда­ вать результат и не знать при этом величину одного или не­ скольких своих аргументов. Например, определенная пользова­ телем функция --------- f( х, у ) < = if х < 10 then х else у ; не всегда требует, чтобы была известна величина у. Хотя вели­ чину х знать необходимо, поскольку надо определить истин­ ность неравенства ’х < 10’. Говорят, что функция f является строгой по отношению к х и нестрогой по отношению к у; а это означает, что величина х требуется обязательно, а величи­ на у — нет. Итак, если в точке вызова вычисляются оба аргумента (т. е. они передаются по значению, что соответствует энергичному вы­ числению), то некоторые из проделанных вычислений могут оказаться лишними. Еще большая неприятность может слу­ читься, если при вычислении нежелаемого выражения аргумента произойдет зацикливание и вся программа будет не в состоянии завершиться, в то время как она завершилась бы при передаче данного аргумента по необходимости. Например, при энергич­ ном вычислении выражения f ( 4, (зацикленное выражение)) мы не получим результат за конечное время. Если к тому же самому выражению применить ленивое вычисление, то ответ будет равен 4. Конечно, это не означает, что при ленивом вычислении можно всегда избежать зацикливания: примером тому является выра­

Виды вычислений

73

жение f( (зацикленное выражение), Е) не способное завершиться независимо от способа вычисления, роскольку всегда требуется знание первого аргумента функ­ ции f. При рассмотрении аргументов предполагается, что пер­ вый аргумент может быть передан и по значению, и по необхо­ димости, поскольку оба этих варианта дают в результате одно и то же поведение функции f. Как будет показано в дальней­ шем, это является важным моментом, поскольку дает основу для некоторых оптимизаций при реализациях функционального языка. Предполагается, что передача параметра по необходимости помогает избежать лишних вычислений и зацикливаний. Приме­ ром тому является применение функции reduce, введенной в пре­ дыдущей главе: reduce( lambda (el, is th e r e ) ) = > if isthere then true else ((N = el), false, L) Пусть список L состоит из чисел [1, 3, 5, 7], необходимо про­ верить наличие в нем 1. При энергичном вычислении, прежде чем будет получен результат, 1 пройдет сравнение с каждым элементом списка (лямбда-выражение, записанное выше, обо­ значим Ь ): reduce(b, false, [1, 3, 5, 7 ]) = b( 1, b (3, b(5, b(7, fa lse)))) (по определению функции reduce) —>b( 1, b(3, b(5, false))) —►b( 1, b(3, false)) -> b ( l, false) (поскольку b ищет значение, равное 1) -> true С другой стороны, при ленивом вычислении величину true мы получаем сразу же: reduce(b, false, [1, 3, 5, 7]) ->b( 1, reduce(b, false, [3, 5, 7 ])) - > true Вычисление заканчивается так быстро, поскольку для b нико­ гда не требуется значения его второго аргумента, иначе говоря, b не является строгой относительно второго аргумента. Если значение первого аргумента оказывается тем числом, которое проверяется на принадлежность к списку, то второй аргумент

74

Часть I. Глава 4

попросту отбрасывается; такой случай и показан в вышеприве­ денном примере, где выражение отброшенного аргумента выде­ лено подчеркиванием. Из данного обсуждения можно было бы заключить, что во избежание лишних вычислений и зацикливания все реализации функциональных языков должны быть ленивыми. Однако суще­ ствуют значительные затраты, связанные с передачей параметров по необходимости, что будет показано в гл. 9 и в других гла­ вах книги. В связи с этим некоторые функциональные языки обладают «строгой семантикой» или «энергичной семантикой», означающей, что все функции являются строгими ко всем своим аргументам и все параметры последовательно передаются по значению. Однако имеются и другие языки, с противоположным подходом, обладающие «ленивой семантикой»; программа на таком языке ведет себя так, будто бы все параметры пере­ даются по необходимости. Это не подразумевает, что все пара­ метры должны на самом деле быть переданы по необходимости, поскольку, как было показано, параметр, который требуется функции всегда, может быть передан любым способом без влия­ ния на поведение программы в целом. Таким образом, «ленивая реализация» подразумевает просто сохранение ленивой семан­ тики, а не повсеместное использование вызова по необходимости, хотя, конечно, и такое возможно. Однако основанием для выбора энергичной или ленивой се­ мантики не может служить только критерий эффективности, поскольку существует еще целый круг задач, при решении кото­ рых предполагается, что реализация, лежащая в основе, являет­ ся ленивой или по крайней мере «частично» ленивой и, таким образом, безопасна в смысле проблемы завершения, описанной ранее. В последующих трех разделах будут представлены инте­ ресные классы таких задач. 4.2. Обработка «бесконечных» структур данных Функция, порождающая бесконечную структуру данных — в данном случае бесконечный список целых чисел х, x + l.x-f+ 2, . . . , — для заданной величины х имеет вид dec from : num -» list( num ); ■ --------from( x) < = x :: from( x + 1); При использовании энергичного вычисления любое примене­ ние функции from никогда не приведет к завершению. На­

Виды вычислений

75

пример: from( 0) —>0 :: from( 1) —>0 :: ( 1 :: from( 2)) -> 0 :: ( 1 :: (2 :: from( 3 ))) и т. д. В случае ленивого вычисления оно прекратится, как только на верхнем уровне будет порожден первый конструктор: frqjn(O) —►0 :: from( 1) Аргументы для :: не вычисляются в точке вызова. Поскольку :: не имеет правил (или правила) вычислений, его вычисление прекращается, а подчеркнутое выражение обозначает невычисленный вызов функции. Запись будет оставаться в таком виде, пока какая-нибудь другая функция не «форсирует» вычисления остатка результирующего списка. Примером может служить функция, подсчитывающая сумму первых п элементов в списке чисел: dec sum : num # list( num ) num ; --------sum( n, x :: 1) < = if n = 0 then 0 else x + sum( n — 1, 1); Вычисление sum (2, from( 1) ) развивается следующим образом (невычисленные вызовы функции опять подчеркнуты): sum( 2, from( 1)) Сопоставление с образцом не может быть проведено до тех пор,, пока не известна структура подчеркнутого выражения, поэтому проводится его вычисление до получения конструктора верхнего уровня: —»sum(2, l::fro m (2 )) —> 1 + sum( 1, from( 2 ) ) Следует помнить, что функция + является строгой по отноше­ нию к обоим аргументам, поэтому она немедленно вычисляет рекурсивный вызов sum, что в свою очередь позволяет выпол­ нять вызов from, давая возможность проводить сопоставление с образцом: -> 1 + sum( 1, 2 ::from (3)) 1 + '(2 + sum( 0, from( 3 ) )) При проверке условия п = 0 в sum имеем true, таким образом.

76

Часть I. Глава 4

получаем 0, не вычисляя !гош (3): - * 1 + ( 2 + 0) -> 3 А сейчас, хотя это и может показаться совершенно не нужным, обратимся к программам, которые либо полагаются на беско­ нечные структуры, либо заключаются в ограниченном исследо­ вании таких структур. Простым примером подобной программы может служить вычисление квадратного корня с использованием алгоритма Ньютона — Рафсона для последовательной аппрокси­ мации, который, должно быть, знаком читателю по школьному курсу математики. Работа программы состоит в порождении последовательности аппроксимаций квадратного корня числа N, начиная с некоторого исходного приближения а0 и далее, уточ­ няя его до тех пор, пока разница между двумя последующими аппроксимациями не окажется в пределах некоторого наперед заданного числа е. Новая аппроксимация ak+i получается из предыдущей ак по следующему правилу: Зк+i = ( ак + N/ak)/2 Для того чтобы убедиться в корректности данного правила, надо рассмотреть предельный случай, когда можно заменить an+i и ап на предел а (предположив, что он существует); в итоге получаем N = а2, что и требовалось. Желательно, конечно, не порождать большего количества аппроксимаций, чем требуется, и поэтому можно было бы ис­ пользовать рекурсивную функцию, которая вычисляет следую­ щую аппроксимацию только в случае, если разница между двумя предыдущими аппроксимациями больше заранее заданного по­ рога. Однако можно получить довольно ясное решение, основан­ ное на бесконечной структуре данных; на деле это решение фактически является новой формулировкой проблемы: «Найти предел ат бесконечного списка аппроксимаций [а0, аь аг, ...] такой, что выполняется соотношение | ага — am_i | < е”. На языке Норе подобная постановка задачи будет выглядеть сле­ дующим образом: dec L im it: list( re a l) # real —►re a l; dec Approximations : real # real —►list( re a l); dec SqRoot : real # real # real —►re a l; --------Limit( a 1 :: ( rest & ( a2 :: — )), epsilon) < = if mod(al — a2) < epsilon then a2 else Limit(rest, epsilon); --------Approximations( a, n ) < = a :: Approximations (( a + ( n /a ) )/2, n ); --------SgRoot ( N, Guess, epsilon) < = Limit( Approximations( Guess, N), epsilon);

Виды вычислений

77

В данном случае функция Approximations строит бесконечный список аппроксимаций, начиная с начального Guess; Limit по­ следовательно просматривает соседние элементы результирую­ щего списка, проверяя соотношение их разности с величиной epsilon. Очевидно, подобная программа требует ленивого вы­ полнения, иначе вычисление списка аппроксимаций никогда не сможет завершиться. Пример применения SqRoot, прослежи­ вающий вычисление SqRoot (4.0, 3.0, 0.01), приведен ниже: SqRoot( 4.0, 3.0, 0.01) —>Limit(Approximations(3.0, 0.01), 0.01) -> Limit(3.0 :: Approximations^. 17, 0.01), 0.01) -> Limit( 3.0 :: (2.17 :: Approximations( 2.01, 0.01)), 0.01) -* Limit( 3.0 ::( 2.17 ::(2.01 :: Approximations( 2.00, 0.01))), 0.01) -> Limit( 3.0 :: (2.17 :: (2.01 :: (2.00 :: Approximations(2.00, 0.01)))), 0.01) — > 2.00 Другим известным примером ленивой программы, результа­ том работы которой будет список всех простых чисел, является «решето Эратосфена». Исходные данные программы — это бес­ конечный список целых чисел 2, 3, 4, 5, . . . , и работа ее проис­ ходит следующим образом: от начала бесконечного списка отде­ ляется одно число и «фильтруется» через оставшийся список, при этом вычеркиваются все элементы, делящиеся на это число. На каждом шаге рекурсии список полученных чисел (а также «отфильтрованных») убирается из основного списка: dec Sieve: list(num )-*-list(num ); dee Filter : num # list( num ) --------Filter(n, m : : l ) < — if (n mod m) = 0 then Filter(n, 1) else m :: Filter( n, 1); --------Sieve( n :: 1) -sum(3, Sieve(2 :: from (3))) —>-sum(3, 2 :: Sieve( Filter( 2, from (3)))) —>-2 + sum(2, Sieve(Filter(2, from (3)))) -> 2-j-sum (2, Sieve( Filter(2, 3 :: from( 4 )))) —>2 4 - sum! 2, Sieve(3 :: Filter(2, fro m (4 )))) —►2 -j- sum( 2, 3 :: Sieve( Filter( 3, Filter(2, from ( 4 ) ) ) ) ) -> 2 + ( 3 + sum( 1, Sieve(Filter(3, Filter! 2, fro m (4 )))))) -> 2 -(- ! 3 -j- sum! 1, Sieve! Filter! 3, Filter(2, 4 :: from( 5 ) ) ) ) ) ) —*■2 + !3 + sum! 1, Sieve! Filter!3, Filter(2, from( 5 ) ) ) ) ) ) —»• 2 -(- ! 3 + sum( 1, Sieve( Filter(3, Filter^, 5 :: from( 6 ) ) ) ) ) ) -> 2 -j- ! 3 + sum! 1. Sieve!Filter(3, 5::Filter(2, fro m (6 )))))) —>-2-1-!з + sum! 1, Sieve!5 :: Filter(3, Filter(2, fro m (6 )))))) —> 2 + ! 3 + sum( 1, 5 :: Sieve ( Filter( 5, Filter( 3, Filter( 2, from( 6 ) ) ) ) ) ) ) —>• 2 + (3 -f- (5 sum( 0, Sieve(Filter(5, Filter( 3, Filter( 2, from( 6 ) ) ) ) ) ) ) ) - *2 + (3 -j-(5 + 0 )) 10

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

Виды вычислений

79

Для иллюстрации понятия циклических структур рассмот­ рим функцию порождения бесконечного списка [1, 2, 1, 2, 1, 2, ...] dec cycle : list( num ) ; --------cycle < = [ I , 2 ](> cycle; Без ленивого вычисления работа этой функции не завершится, поскольку аргумент функции < > будет вычисляться прежде, чем вызывается < ). При этом произойдет обращение к рекур­ сивному вызову cycle и процесс будет повторяться бесконечно. При использовании ленивого вычисления получается cycle = [1, 2]< ) cycle = 1 ::([2]< ) cycle) В данном случае вычисление остановится после того, как кон­ структор :: появится на верхнем уровне. Такой процесс можно определить как циркулярный, или циклический, поскольку все сказанное выше есть описание структуры, замыкающейся на себя:

Подобную функцию можно реализовать таким образом, как будет показано при дальнейшем изложении. 4.3. Сети процессов Используя функцию from, определенную в предыдущем раз­ деле, можно вычислить (бесконечный) список неотрицательных целых чисел, применив в данном случае выражение from(O). Однако существует и другой путь получения аналогичного ре­ зультата— использование приведенной ниже функции Ints: dec IncL ist: num -^-list(num ); --------IncList( n :: 1) < = ( n + 1):: IncList( 1); dec Ints : list(num ); --------Ints < = 0:: IncList( In ts); Таким образом, Ints - > 0 :: IncList( Ints) —*■0 :: IncList( 0 :: IncList( In ts )) —>■0:: 1:: IncList( IncList( In ts))

80

Часть I. Глава 4

—> 0 :: 1 :: IncList( IncList(0 :: IncList( In ts))) —►0 :: 1 :: IncList( 1 :: IncList( IncList( Ints ))) -* 0 :: 1 :: 2 :: IncList( IncList( IncList( In ts))) и т. д. (Конечно же, при ленивой реализации вычисление сможет быть продолжено после второй строки только в том случае, если пользователю потребуется несколько элементов данного списка.) Пока Ints выступала в роли рекурсивной функции. Однако программу можно рассматривать и как описание сети процесса: О

Дугами в этой сети являются бесконечные списки целых чисел (часто называемые потоками), а вершиной IncList — процесс, увеличивающий отдельные компоненты входного списка. Цик­ лическое соединение в сети появляется из-за рекурсивной сущ­ ности функции Ints. Интересной особенностью сети является то, что каждую вершину можно рассматривать как статический процесс. Например, хотя последовательность вычислений, пред­ ставленная выше, показывает, что работа Ints заключается в выполнении рекурсивных вызовов самой себя, ее можно рас­ сматривать как статическую часть программы с одним входным «буфером» и одним выходным, где каждый выходной элемент на единицу больше соответствующего входного элемента. ... 5,4,3,2,1 —»—^ IncList ) —*— ... 6, 5,4, 3,2 В этом отношении IncList можно рассматривать как инте­ рактивную программу, т. е. если поместить какое-либо число п во входной буфер (как часть входного списка), то число n + 1 появится в выходном буфере независимо от того, существуют ли на входе какие-то другие величины. Если из выходного буфера удалить полученное число, а во входной поместить но­ вое, то вся процедура повторится заново. Можно сформировать статическую сеть процессов, «соединяя» соответствующие входы и выходы статических процессов с помощью дуг, представляю­ щих потоки данных. Иногда подобную процедуру определяют

Виды вычислений

8t

довольно расплывчатым термином ”завязывание узлов”. Выше­ приведенная программа является простым примером завязыва­ ния узлов. Под «узлом» тут подразумевается циклическое со­ единение выходной дуги из Ints со входной дугой IncList-npoцесса; это следует из уравнения, определяющего Ints. Существует другой путь представления задачи, заключаю­ щийся в том, что Ints сам выступает в роли процесса:

где Ints имеет теперь такое определение: dec Ints : list( num ) -> list( num ); --------- Ints( s ) < = 0 :: s ; Ints сам больше ”не вяжет узлов” (он нерекурсивен!); для того чтобы выполнить задуманное, а именно для описания соедине­ ний двух процессов, т. е. потоков S1 и S2, необходимо исполь­ зовать рекурсивное let или where: S2 where (S I, S 2 ) = = ( IncList( S 2 ), In ts (S l)) Результат вычисления этого выражения S2 — (бесконечный) список целых чисел. Здесь мы видим, что сами процессы опи­ сываются с помощью функций, а взаимосвязь этих процессов — рекурсивным выражением where. Читатель сам может убе­ диться в том, что данная программа работает только при лени­ вой реализации языка, на котором она написана. В качестве примера немного более сложной сети процессов можно рассмотреть другую известную задачу — программу под­ счета списка чисел Фибоначчи. Числа Фибоначчи задаются в виде: f. = l, f2= l , fn =

fn —l +

Однако 1 п 1 fn fn+1 1 2 fn 6 —

fn—2, П > 2.

эту 2 3 1 2 2 3 3 5

1473

последовательность можно представить так: 4 5 6 7 .. 3 5 8 13 . .. (SI) 5 8 13 21 .,■• (S2) 8 13 21 34 ..■• (S3)

82

Часть I. Глава 4

Принимая элементы каждого ряда за потоки SI, S2 и S3, нетрудно подсчитать величину л-ro элемента в S3, суммируя n-е элементы S1 и S2. Зная fi и f2, можно вычислить первый элемент S3. Следует отметить, что второй элемент S1 в точ­ ности совпадает с первым элементом S2 (который известен), а второй элемент S 2 — с первым элементом S3 (который теперь также известен). Зная вторые элементы S1 и S2, не составляет труда вычислить второй элемент S3. Сам процесс порождения полного списка чисел Фибоначчи в потоке S1 может продол­ жаться бесконечно. Предлагается следующая сеть процесса: S2

S,

Функции F и А, изображенные на этой диаграмме, задаются так: dec F : list( num ) —>•list( num ); dec A : list( num ) =#- list( num ) -> list( num ); --------F( s ) < = I :: s ; --------A( f 1 :: s 1, f 2 ::s 2 ) < = ( f l + f 2 ) : : A ( s l , s2); Этим определяются процессы в вершинах; для определения взаимосвязи этих процессов следует «завязать узел», что снова может быть сделано с использованием рекурсивного where: SI where (S I, S2, S 3 ) = = ( F ( S 2 ) , F (S 3 ), A(S1, S2)) Еще раз можно убедиться, что процессы легко описать с по­ мощью функций, а взаимосвязь процессов — используя рекур­ сивное выражение where. Хотя представленные примеры программ являются не чем иным, как множеством определений рекурсивных функций, их можно рассматривать как спецификации сетей взаимосвязанных процессов. Итак, несмотря на то, что они достаточно удобны для вычисления при любой ленивой реализации языка, логиче­ ские процессы в такой сети можно рассматривать как физиче­ ские процессы, которые даже могут определять отдельные ком­ поненты аппаратного обеспечения. Если придерживаться по­ добной трактовки, то процессы станут выполняться параллельно и можно будет добиться некоторого успеха в спецификации па­

Виды вычислений

83

раллельных систем без обращения к любым дополнительным характеристикам языка, таким, как каналы, буферы или явная синхронизация. Это довольно интересный аспект технологии функционального программирования, и для более детального ознакомления читатель может обратиться к работам [41, 55]. 4.4. Вычисление с «неизвестным» Для завершения обсуждения методов ленивого программи­ рования обратимся к способу, в котором результат работы функции зависит от величин, которые неизвестны до тех пор, пока вычисление не закончено. Подобное может показаться аб­ сурдным, однако иногда имеет место, когда реализация языка является ленивой. В качестве примера рассмотрим функцию, заменяющую каждый элемент в списке положительных чисел на максимальный элемент этого списка. Назовем функцию ReplaceByMax. Как обычно, задача разбивается на две части: сначала вы­ числяется максимальный элемент списка, затем каждый эле­ мент списка заменяется на этот максимальный элемент. Напри­ мер (используя функции шах, шар и reduce, определенные в предыдущих главах): dec ReplaceByMax : list( num ) -> list(num ); --------ReplaceByMax( 1) < = map( lambda reduce(max, 0, 1), 1); Тут все очень точно и достаточно ясно, но существует неболь­ шое неудобство, заключающееся в том, что список необходимо просматривать дважды. Однако, используя рекурсивные where(или let-) выражения в сочетании с ленивым вычислением, можно проделать ту же самую операцию за один просмотр. Функция, осуществляющая это, выглядит следующим образом: dec ReplaceByMax : list( num ) -* list( num ); --------ReplaceByMax( 1) < = Result where (Result, big ) = = reduce] f, (nil, 0), 1) where f —= lambda (x, (r, m ) ) —> ( big :: r, if x > m then x else m ); Вторым компонентом пары, генерируемой с помощью функции reduce, является максимальный элемент исходного списка. Ин­ тересная особенность программы состоит в том, что все эле­ менты списка, образующие первый компонент возвращенной функцией f пары, являются big. Однако величина big неизвест­ на, пока выполняется функция reduce! Причина этого заклю6f

84

Часть I. Глава 4

чается в том, что нет необходимости знать значение big, пока не будет завершено выполнение функции reduce. Посмотрим, что произойдет, если попытаться обработать элементы резуль­ тирующего списка. Например: m where ( ш : : _ ) = = ReplaceByMax( [ 5, 1, 4]) Применение функции ReplaceByMax выглядит следующим об­ разом: ReplaceByMax([ 5, 1, 4 ]) -►Result where (Result, big) = = reduce(f, (nil, 0), [5, 1, 4 ]) where f —= lambda(x, (r, m))= > (b ig ::r, if x > m then x else m) = Result where (Result, b ig ) ===== f(5, f( 1, f(4, (nil, 0 ) ) ) ) -►Result where (Result, big) ===== f( 5, f( 1, (big:: nil, 4)) ) -►Result where (Result, big) = = f( 5, ( big :: ( big :: n il), 4 )) -►Result where (Result,b ig ) = = ( big :: ( big :: ( big :: n il)), 5) Только теперь можно продолжать сопоставление ( m : : _ ’>. К данному моменту величина big известна и равна 5. Получен­ ный результат есть голова итогового списка, состоящего из мак­ симальных элементов, равных 5. Эта версия ReplaceByMax далеко не так очевидна, как предыдущая, и, вероятно, читателю придется более детально ознакомиться с программой, прежде чем он убедится, что она реализует необходимое преобразование. Следует, однако, вспом­ нить, что единственная цель, которая преследовалась, — это уменьшение количества просмотров списка от двух до одного, т. е. оптимизация. Некоторая непонятность этой версии про­ граммы еще раз напомнила старую поговорку, что уж если и проводить оптимизацию, то только после того, как программа станет абсолютно ясна. Использование же подобного стиля программирования при разработке программы будет только запутывать. Как уже было сказано и в чем можно будет убедиться по ходу дальнейшего изложения, поддержка ленивого вычисления является очень дорогой, и для того, чтобы избежать его при реализациях функционального языка, затрачиваются большие усилия всякий раз, когда это возможно. Например, при анализе ленивой функциональной программы часто определяется, что функция, заданная пользователем, является строгой в одном или нескольких своих аргументах. Эти строгие аргументы затем могут передаваться по значению, давая некоторое увеличение эффективности без изменения поведения программы при лени­ вой реализации. Подобная форма оптимизации, известная как анализ строгости, более детально обсуждается в гл. 20.

Виды -вычислений

85

С этой позиции следует разъяснить семантику вызовов в самом Норе. Существуют две реализации языка Норе, на обе будут даваться ссылки в оставшихся главах книги, поэтому не мешает остановиться на них подробнее. В стандартной реали­ зации Норе все функции вызываются по значению, за исклю­ чением функций-конструкторов, вызываемых по необходимости. Это попросту означает, что аргумент конструктора не вычис­ ляется, пока он не будет передан не являющейся конструктором функции как результат ее выбора при сопоставлении с образ­ цом. В ленивой реализации Норе все функции вызываются по необходимости. Это означает, что строгие примитивные функции получают свои аргументы в невычисленной форме и, таким об­ разом, должны сами проводить их вычисление, прежде чем примитив можно будет использовать. Для пояснения следует обратить внимание на то, что пример в разд. 4.2 (не последний, cycle) будет работать при использовании любой реализации Норе, а примеры в разд. 43 и 4.4 — только при ленивой реа­ лизации. В гл. 9 будет показано, каким образом энергичное и ленивое вычисления могут быть применены в интерпретаторе для функциональных языков, написанном на языке Норе, и то, как две реализации Норе порождают различное поведение этого интерпретатора. Резюме • Функциональные языки могут выполняться либо энергично, либо лениво (возможно также использование различных комби­ наций этих двух способов). • Энергичное вычисление соответствует вызову по значению и в общем случае более эффективно, хотя может порождать лишние вычисления или «ненужное» зацикливание. • Ленивое вычисление соответствует вызову по необходимости и приводит к завершению программы всякий раз, когда оно возможно. • Задачам, использующим для своего решения бесконечные структуры данных, необходимы ленивые конструкторы. „ • Некоторым программам, строящим циркулярные струк­ туры, для завершения требуется полностью ленивая семан­ тика. • Сети процесса обеспечивают альтернативную модель вычис­ лений некоторых бесконечных выражений. • Ленивое вычисление может быть использовано для миними­ зации числа просмотров структур данных, но это связано с не­ обходимостью написания сложных программ.

86

Часть I. Глава 4

• Существуют две реализации Норе: стандартная, при которой функции-конструкторы вызываются по необходимости, а все другие функции по значению, и ленивая, когда все функции вы­ зываются по необходимости. Упражнения 4.1. Обычно генератор случайных чисел включает в себя функ­ цию R, которая при вызове со специальной величиной, назы­ ваемой источником, возвращает псевдослучайное число в диа­ пазоне от 0 до 1 и новый источник, который может быть использован при последующем вызове R. Предложите, как бес­ конечный список, определенный с использованием R, можно использовать вместо многократных вызовов R. Как получается «следующее» случайное число? 4.2. Пусть выражение Е вычисляет список символов, который при выдаче на терминал покрывает 20 экранов текста. Объяс­ ните, что может увидеть пользователь, когда значение Е вы­ числено и затем распечатано с использованием полностью энергичной реализации, а потом снова — применяя ленивую реализацию. 4.3. Рассмотрим пример cycle, данный в конце разд. 4.2. Аль­ тернативное определение этой функции выглядит следующим образом: --------cycle < = 1 :: (2 :: cycle); Требуются ли тут все возможности ленивого вычисления? Ка­ ково важное отличие между двумя версиями с точки зрения возможности завершения работы программы? 4.4. а. (Прежде чем заняться этой задачей, следует вернуться к упражнению 3 . 4 ( b ) . ) С помощью reduce, шар и compose определите Hope-функцию pipe, строящую линейный конвейер из заданного списка функций [Е, f2, .. . , fn]. (Подсказка: ли­ нейный конвейер функций Е, f2, . . . , fn описывает систему обра­ ботки типа «конвейерной полосы», где каждый элемент потока аргументов по очереди обрабатывается функциями fn, fn-i, ... ... и т. д. до fi. Функции fi можно рассматривать как отдель­ ные физические модули обработки, которые могут работать одновременно. Например, в то время как Е применяется к Ему элементу потока аргументов, f2 работает с ( i +l ) ^M элементом и т. д.) Опишите поведение конвейера при использовании обыч­ ного ленивого вычисления. Особое внимание обратите на то, что произойдет, если поток аргументов бесконечен. б. Напишите выражение, использующее pipe для проверки на пересечение двух списков объектов S и Т. Конвейер, кото­

Виды вычислений

87

рый надо построить, выглядит следующим образом: ■sn sn_-j... s2 s, —И —[tn,-,!— - -. ~*~[t7|—►пересечение где ti ( l ^ i ^ m ) есть элементы списка Т, sj ( l ^ j ^ n ) — элементы S, а пересечение — поток величин, каждая из которых содержится и в S, и в Т. Ящик, содержащий величину t, пред­ ставляет собой процесс, проверяющий элементы S на равен­ ство t. (Подсказка: Пусть величины, проходящие через кон­ вейер, представляют собой пары вида (величина, флаг), где «флаг» показывает, принадлежит ли данная величина множе­ ству пересечения.) 4.5. Используя метод, подобный описанному в разд. 4.4, опре­ делите функцию mintips, входными данными которой является дерево чисел, а выходными — изоморфное дерево, где каждый элемент заменен на минимальный в исходном дереве; при этом допускается лишь один обход дерева.

Глава 5 ДРУГИЕ СТИЛИ ФУНКЦИОНАЛЬНОГО ПРОГРАММИРОВАНИЯ

В предыдущих трех главах мы познакомились со стилем функционального программирования на языке Норе. Однако Норе никоим образом не является ни единственным, ни оконча­ тельным (в любом смысле этого слова) функциональным язы­ ком. Как мы видели, важными особенностями функционального языка, отличающими его от языка любого другого типа, яв­ ляются прозрачность ссылок и детерминизм. Последняя особен­ ность служит отличительным фактором между функциональ­ ными и реляционными языками, такими, как Пролог [23]. Это оставляет значительный простор для вариаций в таких обла­ стях, как строгая типизация, определенные пользователем типы данных, функции высшего порядка и правила вычисления, а также для косметических различий, например в синтаксисе языка. В этой главе мы введем некоторые из альтернативных стилей функционального программирования, рассмотрев вкрат­ це языки Miranda, Лисп и FP. Эти языки вместе с самим язы­ ком Норе дают хорошее представление о различных подходах к разработке функциональных языков. Раздел 5 1 описывает язык .Miranda, являющийся строго ти­ пизированным, очень похожим на Норе, но значительно отли­ чающимся от последнего подходом к рассмотрению функций. Раздел 5.2 описывает Лисп, который является нетипизированным языком обработки списков и в котором программы и дан­ ные имеют одинаковое представление. Раздел 5.3 описывает FP, который также допускает только один тип данных (сим­ метричные списки) и значительно отличается от других функ­ циональных языков тем, что при программировании на нем рас­ суждения идут на уровне функций, а не на уровне объектов. Это свойство делает FP олень мощной нотацией для представ­ ления формальных преобразований функциональных программ и дает основу для методов программных преобразований, опи­ санных в гл. 18.

Другие стили функционального программирования

89

5.1. Miranda *> Общий подход, принятый в языке Miranda, во многом похож на подход языка Hope. Miranda — это строго типизированный язык высокого уровня, поддерживающий типы данных пользо­ вателя и полиморфизм. Он является преемником двух более ранних языков, разработанных Тернером, а именно языка SASL [79] и языка K.RC [82]. Главное отличие между языками Miranda и Норе в том, что Miranda — это «карринговый» язык, т. е. в нем объекты, значением которых является функция, строятся путем частичного применения существующих функций, а не с помощью явных лямбда-выражений, как мы делали это в гл. 4. Miranda имеет ленивую семантику, так что все функции вызываются по необходимости, но можно специфицировать стро­ гие конструкторы, пометив нужным образом аргументы кон­ структора. Этот аспект, однако, не обсуждается здесь. 5.1.1. Структура программы языка Miranda Подобно языку Норе, программа на языке Miranda состоит из множества определений. Однако в отличие от Норе не тре­ буется, чтобы тип каждого определения был специфицирован программистом. Это не означает, что Miranda является нетипизированным языком, поскольку тип каждого выражения выво­ дится автоматически программой проверки типов. (Фактически то же самое справедливо для языка Норе: тип каждой функ­ ции определяется автоматически программой проверки типов независимо от уравнений типов, заданных программистом. Един­ ственное отличие состоит в том, что в Норе выведенные типы последовательно проверяются на совместимость с типами, за­ данными программистом.) В качестве простого примера программы на языке Miranda здесь приведена версия программы reverse, данной в гл. 3 на языке Норе. Эта программа обращает заданный список объек­ тов, используя накапливающий параметр: rev L = rev2 L [ ] rev2 [ ] a = a rev2( x : 1) a = rev21( x : a ) Каждое определение имеет левую и правую части, разделенные символом = (эквивалентным < = в языке Норе). Подобно языку Норе, определения могут содержать образцы в левой *> M ir a n d a ™ — э т о т о р г о в а я

м ар к а ф ирмы

R esea rch S o ftw a r e

L td .

90

Часть I. Глава 5

части; приведенный пример показывает использование сопостав­ ления с образцом для списков: [ ] обозначает пустой список (эк­ вивалент nil в языке Норе), и : является инфиксным конструк­ тором списков (эквивалентным :: в Норе). Подобно языку Норе, в языке Miranda можно использовать упрощенную форму записи для выражений списков и для спи­ сков символов; выражение х, : (х2: ( х3

( хп : [ ] ) . . . ))

может быть записано в виде [х,, х2, х3, . . . . хп] а в случае если каждый Xi является символом, все они могут быть записаны в одну строчку и заключены в двойные кавычки, например: "Miranda" Однако в отличие от языка Норе в языке Miranda возможна сокращенная форма записи для арифметических последователь­ ностей. Конечная последовательность целых может быть скон­ струирована с помощью указания первого и последнего элемен­ тов, разделенных двумя точками .. . Например, список [ 1, 2, 3, 4, 5]

можно записать в виде [1 •• 5]

Используя ту же форму записи, мы можем специфицировать бесконечные списки, просто опустив верхнюю границу. Напри­ мер, выражение [1 ••] обозначает (бесконечный) список положительных целых (вспом­ ним, что Miranda — это ленивый язык, так что бесконечные структуры данных могут быть выражены в нем без проблем). 5.1.2. К арринг

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

Другие стили функционального программирования

91

ление вида f х у z = ... мы можем обычным образом интерпретировать f как функцию трех аргументов х, у, г или, как в языке Норе, в качестве функ­ ции от одной тройки аргументов. Однако в языке Miranda в действительности f является функцией высшего порядка только от одного аргумента х. Результатом применения f к аргументу Еь который мы записываем в виде fE, является другая функция, снова только от одного аргумента у. Применение этой функции к следующему аргументу Е2 снова дает функцию от одного аргумента ъ. Полное применение f записывается в виде fE1E2E3 но правильно читать эту запись нужно следующим образом: ( ( ( f E 1) E 2) E 3) Однако существует соглашение, по которому функция приме­ няется всегда к левому аргументу, так что скобки можно опус­ тить без изменения смысла выражения. Идея обработки функции от п аргументов как конкатена­ ции п функций от одного аргумента называется каррингом (по имени математика Карри (Н. В. Curry)). В языке Норе объект, имеющий своим значением функцию, создается с помощью клю­ чевого слова lambda; в языке Miranda аналогичный объект со­ здается путем применения определенной функции к меньшему числу аргументов по сравнению с тем, что указано в левой ча­ сти ее определения; иногда это называют частичным примене­ нием. Например, функция следования на множестве целых чи­ сел, которая может быть записана на языке Норе с помощью лямбда-выражения lambda х = > х + I может быть записана на языке Miranda в виде частичного при­ менения примитивной функции + к аргументу 1: ( + )1 (Скобки превращают инфиксную функцию + в префиксную функцию.) Оба выражения представляют функцию, которая «прибавляет единицу к некоторому целому». В качестве еще одного примера рассмотрим функцию та р , определенную на

92

Часть I. Глава 5

языке Норе в разд. 3.1. Она имеет следующее определение на языке Miranda: шар f[ ] = [ ] map f(x : l) = (f х ) : ( т а р П ) Функции, которые мы применяем к списку, могут сами быть выражены частичным применением. Например, выражение шар(( + ) 1) L увеличивает на единицу каждый элемент L, а выражение шар(шах 0 )L возвращает список, в котором каждый элемент L заменен мак­ симумом из 0 и этого элемента. Эквивалентное определение на языке Норе включает использование lambda: шар( lambda n = > m a x ( n , 0), L) Одним из следствий формы записи в языке Miranda яв­ ляется то, что все функции должны иметь имена. В языке Норе мы вводим вложенные функции, используя lambda-выражения; в языке Miranda это достигается с помощью обычного опреде­ ления функции внутри where-выражения. Например, функция map(f 5) L where f x y = y + x*x прибавляет 25 к каждому элементу L. Заметим, что введенная функция может быть рекурсивной благодаря тому, что имеет имя. Заметим также, что нет необходимости в том, чтобы все функции были карринговыми, поскольку Miranda предоставляет также механизм для построения кортежей и для композиции этих кортежей с помощью сопоставления с образцом. Синтаксис кортежа в языке Miranda идентичен синтаксису кортежа в Норе, поэтому мы могли бы определить приведенную выше функцию rev следующим образом: rev( L ) = rev2( L, [ ]) rev2( [ ], a ) = a rev2(xil, a) = rev2(l, x : a ) 5.1.3. Условные выражения Условные выражения в языке Miranda записываются с по­ мощью примитивных булевых функций, выражающих условия, а не с помощью ключевых слов, таких, как if, tren и else.

Другие стили функционального программирования

93

В качестве примера здесь мы приводим функцию шах, которая возвращает максимальное из двух чисел ш и п : шах m п = m, п,

ш> п ш и < = (соответственно «больше» и «меньше или равно») являются примерами примитивных булевых функций, работаю­ щих с объектами типа num; num — это базовый тип в языке Miranda, так же как в Норе, но в отличие от языка Норе он включает как целые, так и вещественные числа. В результате существует единственное правило для функций, подобных > , и то, какой из четырех вариантов применения имеет место, опре­ деляется при вызове функции, т. е. во время выполнения. В этом проявляется отличие от языка Норе, где выбор применяемого правила определяется перед вычислением программы (т. е. во время компиляции). Другими базовыми типами языка Miranda являются bool и char, эквивалентные базовым типам truval и char языка Норе. 5.1.4. Абстракции списков Абстракции списков, называемые также включениями спи­ сков, дают элегантный метод сжатого описания определенных операций обработки списков. Например, следующее выражение вычисляет (бесконечный) список четных положительных целых, используя выражение [1..] в качестве генератора бесконечного списка положительных целых, из которого затем удаляются те элементы, которые не делятся на два: [п 1п < — [ 1 ..]; пгеш2 = 0] геш является эквивалентом mod в языке Норе, а символ < — (являющийся аппроксимацией символа е ) обозначает опера­ тор принадлежности к списку. Поэтому данное выражение можно читать следующим образом: [п | п < —: [ 1 .. ]; п геш 2 = 0] список всех п, таких что п входит в [1, 2, 3, ...] и п делится на 2 В качестве примера использования этой формы записи здесь приводится функция языка Miranda для вычисления (бесконеч­ ного) списка простых чисел с помощью решета Эратосфена, описанная в гл. 4: primes = sieve[ 2 .. ] where sieve( n : 1) = n : sieve[m < — 1] m rem n ~ = 0]

94

Часть I. Глава 5

Абстракция списка [ш < — l | m r e m n ~ = 0] удаляет из 1 все элементы, которые делятся на п, и идентична поэтому функции Filter языка Норе, данной в гл. 4. 5.1.5. Типы данных, определенные пользователем В языке Miranda мы можем определять наши собственные типы данных во многом аналогично тому, как мы это делаем в Норе. Синтаксис определения типа похож на БНФ-нотацию для описания синтаксиса языка. Например, тип данных tree (дерево), который мы определили в гл. 3 на языке Норе, может быть определен на языке Miranda следующим образом: tree Empty) Tip numjNode tree num tree .Заметим, что конструкторы также могут быть карринговыми, и это объясняет синтаксис данного определения. Символ :: = аналогичен символу ===== в языке Норе, а | аналогичен + + • Заметим, что имена конструкторов по правилам языка Miranda начинаются с большой буквы. Функция для представления де­ ревьев в виде списков, которая была определена на языке Норе в гл. 3, может теперь быть определена обычным образом с по­ мощью нескольких уравнений: flatten Empty = f ] flatten( Tip n) — [n] flatten(Node left value right) = ==(flatten left) + + ( value : (flatten right)) H—|---- это примитивная функция присоединения списков, экви* валентная функции Iist( alpha ); --------tail( _ :: 1) < = 1; В Лиспе нет возможности сопоставления с образцом, а функ­ ции, подобные приведенным выше функциям head и tail, яв­ ляются примитивами языка и используются для выделения тре-

Другие стили функционального программирования

97

буемого элемента (или элементов) заданного S-выражения. Су­ ществуют четыре примитива для сборки и разборки S-выра­ жений: CAR

эквивалент head

CDR

эквивалент ta il (произносится "кудер" (" c o u ld e r r " ) )

CONS

эквивалент :: в языке Норе

А ТО М

проверяет, является ли его аргумент атомом

Примитив АТОМ должен возвращать некоторое представление булевых величин true или false; они представляются специаль­ ными атомами Т и F соответственно (сравните с языком Норе, в котором истинностные величины поддерживаются в качестве базовых). Функции декомпозиции имеют довольно странные имена, которые совершенно не соответствуют выполняемым ими операциям. Эти имена происходят из самой ранней реализации Лиспа, в которой первый элемент неатомарного S-выражения (голова) был доступен через специальный машинный регистр, называемый «адресным регистром», а хвост — через другой спе­ циальный регистр, называемый «декрементным регистром». Го­ лова и хвост S-выражения могли, таким образом, быть доступ­ ны при обращении к содержимому адресного регистра (Con­ tents of Address Register) и к содержимому декрементного ре­ гистра (Contents of Decrement Register) соответственно. Отсюда имена CAR и CDR. В Лиспе мы представляем применение функции f к набору аргументов а\, . . . , ап одним S-выражением: ( f а1 а2 . . . ап) Это означает, что и применение в целом, и аргументы приме­ няемой функции представляются в виде списков (S-выражений). Следует заметить, что большинство диалектов Лиспа имеют строгую семантику, т. е. выражения аргументов а ь . . . , ап вы­ числяются до вызова функции f. Однако существуют исключе­ ния: диалект Lispkit, например, имеет ленивую семантику. Недостатком представления применений функций в Лиспе является то, что, например, S-выражение (1 2 3 )

имеет такой же формат, как и применение функции. Вследствие этого мы можем читать данное выражение как применение 1 к списку аргументов (2 3), что не соответствует действительно­ сти. Для решения этой проблемы все константы в Лиспе долж­ ны б|,ш. шключены в кавычки. В действительности это дости7



1473

98

Часть I. Глава 5

гается применением функции QUOTE и к (постоянному) S-вы ражению. QUOTE возвращает свой аргумент без изменений, т. е. не интерпретирует его как выражение, которое необходимо вычислить. Так, например, постоянные S-выражения 62 (4 —7) (Torn Browns Schooldays) должны записываться в виде (QUOTE 6 2 )(QUOTE (4 -7 ))(Q U O T E (Tom Browns Schooldays)) соответственно. Дадим несколько примеров применения прими­ тивов, приведенных выше: Выражение

Результат

( C D R Q UO TE ( 2 ) ) ( CAR ( CDR ( Q U O T E ( —23 NIL NIL (1 4 ) ) ) ) ) ( CON S ( Q U O TE 1 ) ( Q U O TE N IL ) ) ( ATOM ( Q U O TE NIL ) )

NIL NIL ( 1) T

'Теперь можно спросить, что произойдет, если применить CDR к списку, состоящему только из одного компонента. В резуль­ тате мы получим специальный атом NIL, который в Лиспе обо­ значает пустой список. NIL обозначает такой же список, как и конструктор nil языка Норе. Если мы хотим записать NIL как часть другого выражения, мы должны, конечно, заключить его в кавычки. Например: Выражение (C A R (Q UO TE ( (C D R ( QUOTE ( ( CONS ( QUOTE (Q UO TE (A TO M (Q U O TE (A TO M (Q U O TE

1 2 3 ) ) ) 1 2 3))) A ) (Boys L ife ))) 1 )) ( 1 2 ) ) )

( CAR ( CDR ( Q U O T E ( CAR CDR Q U O T E ) ) ) )

Результат 1 ( 2,

3 )

(A T F

BoysL ife)

CDR

5.2.2. Условные выражения и примитивные функции Условное выражение в Лиспе — это просто S-выражение из четырех компонентов. Первым элементом является специальный атом IF; второй элемент — это выражение предиката (которое возвращает Т или F в допустимом условном выражении), а оставшиеся два элемента являются истинной и ложной ветвями условного выражения. Например, выражение языка Норе if

х=

0 th e n 0 e l s e

х—1

Слово ’’Q U O TE ” означает ’’кавычки”. — Прим, перев.

Другие стили функционального программирования

99

представляется в Лиспе следующим образом: (IF (EQX( QUOTE 0) ) ( QUOTE 0 )( MINX ( QUOTE 1))) EQ и MIN являются еще двумя примерами примитивных функ­ ций. Применения таких функций записываются в префиксной форме, а не в инфиксной, как в языках Норе и Miranda. Это делает синтаксис применения примитивной функции идентич­ ным синтаксису применения функции пользователя. Список при­ митивных функций, на которые мы будем ссылаться, приведен Таблица 5.1. Примитивы Лиспа ADD ATOM CAR CDR CONS EQ GTR LESS MIN MUL

Арифметическое сложение П роверяет, является ли его аргумент атомом В озвращ ает голову списка В озвращ ает хвост списка Строит новый список из головного и хвостового элементов П роверка на равенство П роверка на больше Проверка на меньше Арифметическое вычитание (минус) Арифметическое умножение

в табл. 5.1, хотя могут поддерживаться также многие другие примитивы. 5.2.3. Определение функций В языке Норе мы можем непосредственно записать обозна­ чающее функцию выражение с помощью лямбда-выражения. Например, функция, увеличивающая на единицу значение лю­ бого заданного аргумента, может быть записана в виде la m b d a х = > х + 1 В Лиспе существует идентичный механизм, только функция вводится с помощью специального атома LAMBDA, а не с по­ мощью ключевого слова (la m b d a ), как в Норе. Лямбда-выра­ жение языка Лисп является S-выражением из трех компонен­ тов: первый компонент — это атом LAMBDA, второй — это спи­ сок имен формальных параметров функции, и третий — это тело функции. Например, функция, увеличивающая на единицу за­ данное значение х, записывается следующим образом: (LAMBDA ( х ) ( ADD х (QUOTE 1))) Приведенная ранее функция максимума из двух чисел, --------max( m, n ) < = if m > n t h e n m e l s e n * i*

т. е ,

100

Часть I. Глава б

на языке Норе, записывается на языке Лисп в виде (LAMBDA (ш, п )( IF (GTR m п) m п)) Заметим, что ссылки на формальные параметры функции запи­ сываются с помощью переменных ш и п соответственно. Они отличаются от атомов ш и п , которые должны заключаться в кавычки. Например, функция, добавляющая атом А к началу заданного списка, может быть записана в виде (LAMBDA (A)(CONS (QUOTE А) А) ) Здесь мы видим, что кавычки делают атомы отличными от пе­ ременных. Как и в языке Норе, в языке Лисп нет возможности сделать лямбда-выражение рекурсивным, поскольку отсутствует имя, на которое функция могла бы ссылаться в своем теле для вызова самой себя. Однако мы можем дать имя выражению, заключив лямбда-выражение внутри так называемого LETREC-выражения (рекурсивного let-выражения). В языке Норе существуют два способа записи рекурсивной функции или набора взаимно рекурсивных функций: мы можем либо объявить новую функ­ цию с помощью dec и затем дать ее определение, либо исполь­ зовать рекурсивное let- или where-выражение. В языке Лисп все рекурсивные функции вводятся с помощью LETREC. На­ пример, выражение языка Норе let f ===== El in E2 где El содержит ссылку на f, может быть записано в языке Лисп следующим образом: (LETREC Е2' (f El ' ) ) где El ' и Е2' — это эквиваленты языка Лисп для Е1 и Е2. При­ ведем в качестве примера функцию языка Лисп, вычисляющую факториал: (LETREC fac (fac (LAMBDA (x ) (IF (EQ x (QUOTE 0)) (QUOTE 1) (MUL x (fac (MIN x (QUOTE 1 ) ) ) ) ) )

Другие стили функционального программирования

101

Фактически это запись на языке Лисп следующего выражения языка Норе: le t fac ===== la m b d a х = > if х = 0 th e n 1 e l s e x * fac( x — 1 ) in fac Мы можем использовать LETREC для определения взаимно ре­ курсивных наборов функций, заключая определения этих функ­ ций внутри одного и того же LETREC-выражения. Например, выражение для вычисления 3! может быть записано в виде (LETREC (f (QUOTE 3)) (f (LAMBDA (x ) (IF (EQ x (QUOTE 0)) (QUOTE 1) (g * ) ) ) ) (g(LAMBDA (x ) (MUL x (f (MIN x (QUOTE 1 ) ) ) ) ) ) ) что аналогично следующему выражению языка Норе: l e t (f, g ) ===== ( la m b d a x = > if x = 0 t h e n 1 e l s e g ( x ) , la m b d a x = > x * f( x — 1 ) ) in f(3) Мы можем выразить набор нерекурсивных определений с по­ мощью LET-выражения, которое имеет такой же формат, как и LETREC-выражение. Например, выражение языка Норе le t (a, b ) = = ( h ( x ) , h(у )) in f( a, b) + g(b, а) может быть записано в виде (LET (ADD (f a b ) ( g b a )) (a (h x )) ( b ( h у ))

) 5.2.4. Функции высшего порядка в языке Лисп Используя QUOTE и LAMBDA совместно, мы можем пере­ давать функции (лямбда-выражения) как параметры другим функциям. В качестве примера приведем определенную ранее функцию шар, применяющую заданную функцию f к каждому элементу списка L. Рассмотрим применение шар, где в качестве функции f используется функция, увеличивающая свой аргумент

102

Часть I. Глава 5

на единицу. Для того чтобы эту функцию можно было пе­ редать в качестве параметра, определяющее ее лямбда-выраже­ ние заключается в кавычки: (LETREC (map (QUOTE (LAMBDA ( х ) ( ADD х ( QUOTE 1))) (QUOTE (1 2 3))) (map (LAMBDA (fL) (IF (EQ L (QUOTE NIL)) (QUOTE NIL) (CONS (f (CAR L ) ) ( MAP f ( CDR L) ) ) )) ) ) Заметим, что мы должны заключать в кавычки лямбдавыражение, поскольку иначе оно будет интерпретироваться как применение функции LAMBDA к списку аргументов ( (х )( ADD х( QUOTE 1 ) ) ) . Однако основная проблема при использовании QUOTE в этом контексте в том, что если внутри заключенного в кавычки выражения существуют ссылки на пе­ ременные, не «связанные» формальными параметрами некото­ рого лямбда-выражения внутри кавычек, то может возникнуть конфликт имен, когда данное лямбда-выражение в конце кон­ цов применяется. Например, когда мы пишем (LET (LAMBDA (x)(ADD х n) ) (n (QUOTE 1)) то совершенно ясно, что п в лямбда-выражении ссылается на значение 1. Однако если мы заключим в кавычки это выраже­ ние и передадим его другой функции, то связь n с 1 может быть потеряна. Например: (LET (f (QUOTE (LAMBDA (x)(ADD x n ) ) ) ) (n (QUOTE 1)) (f (LAMBDA ( g ) ( LET ( g n) (n (QUOTE 4 ) ) ) ) ) ) Теперь существуют две возможные интерпретаци результата: либо он равен (ADD 4 1), т. е. 5, либо (ADD 4 4), т. е. 8, в зависимости от того, как мы обрабатываем вхождение п в теле заключенного в кавычки лямбда-выражения, т. е. в за­ висимости от того, является ли п связанным статически или динамически соответственно. Различные реализации языка Лисп отличаются наиболее значительно именно стратегией связыва­ ния. Первые реализации Лиспа использовали динамическое свя-.

Другие стили функционального программирования

ЮЗ

зывание, но теперь считается, что это было ошибкой, и поэтому самые последние диалекты (такие, как Lispkit) поддерживают статическое связывание. Общий механизм статического связы­ вания описан в гл. 9. Вероятно, самой удивительной (и привлекательной) особен­ ностью языка Лисп является простота его синтаксиса. Как мы видели, программа на Лиспе строится только из восьми типов выражений: переменных, констант, примитивов, условных вы­ ражений, применений, лямбда-, let- и letrec-выражений. Все «специальные» выражения, подобные true, false и nil, представ­ ляются как атомы. Более того, программа на Лиспе сама яв­ ляется S-выражением, т. е. не существует явных ключевых слов. Это приводит к одинаковому представлению программ и данных. Хотя мы придаем специальный смысл атомам LET, LETREC, LAMBDA и т. д., они тем не менее являются простыми атомами. Если эти атомы встречаются в нужной позиции (т. е. в начале списка), мы можем интерпретировать их как ключе­ вые слова. Подобным образом, если атомы ADD, MIN, CAR и т. д. появляются в голове списка, мы можем интерпретировать их как применения примитивных функций. Таким образом, язык Лисп почти не имеет синтаксических правил, отличных от правил формирования S-выражений. В этом отношении он ра­ дикально отличается от языков, подобных языку Норе, БНФ которых зацимает несколько страниц текста. 5.3. FP До сих пор мы рассматривали языки, в которых функции пользователя выражаются в терминах преобразований объек­ тов, имеющих явным образом указанные имена. Например, ко­ гда мы пишем что-нибудь вроде --------f(x) < = 1 + х; в языке Норе, мы указываем не только имя функции f, но также и имя объекта, к которому f в конечном счете применяется, т. е. х. Поэтому определение функции в языке, подобном Норе, дает имена объектам, передаваемым функции, и описывает затем, что делать с этими переданными аргументами. Поначалу при напи­ сании программы этот подход является самым простым для по­ нимания: определение функции принимает вид набора уравне­ ний, определенных для всех возможных «форм» объектов. В предшествующих главах мы видели несколько примеров функ­ ций, определенных таким образом. Однако одной из наиболее «сильных» сторон функциональ­ ных языков является то, что они поддаются формальным преоб­

104

Часть I. Глава 5

разованиям. Одной из их форм является преобразование про­ граммы, использующее свойство прозрачности ссылок функцио­ нальных программ, чтобы «заменить подобное подобным», улуч­ шив при этом характеристики программы во время выполнения. Как мы увидим в части III этой книги, формальные преобразо­ вания часто легче выразить, если ссылки на объекты могут быть удалены из исходной программы, в результате чего каж­ дая функция редуцируется к форме, свободной от объектов (или свободной от переменных). В этом разделе мы кратко рассмотрим функциональный язык, называемый FP, где каждая функция выражается именно таким образом. FP может быть использован как язык програм­ мирования, но в этой книге мы будем использовать его скорее как промежуточную форму записи, в которой могут быть выра­ жены формальные преобразования. Это относится в основном к части III, но в гл. 13 язык FP также используется для введе­ ния категорийной комбинаторной логики первого порядка. 5.3.1. Компоненты FP-системы FP-система состоит из трех компонентов: объектов, примити­ вов и комбинирующих форм.О бъ ек ты

Хотя объекты являются частью каждой FP-системы, не суще­ ствует механизма, который позволял бы функции пользователя ссылаться на объект непосредственно; объекты появляются только на этапе выполнения в качестве аргументов и возвра­ щаемых результатов вызова функции. Тем не менее обсуждение объектов в языке FP уместно, поскольку чтобы определить смысл различных FP-функций, мы должны рассмотреть, что происходит, когда мы применяем эти функции к заданным объ­ ектам-аргументам. В FP-системе существуют три типа объектов, а именно JL (читается «основание»), атомы и последовательности. Объект _1_ является «неопределенным» объектом и представ­ ляет ошибочное состояние. Например, попытка вычислить вы­ ражение 1/0 даст X, поскольку деление не определено, когда знаменатель равен нулю. Когда мы говорим, что при вычисле­ нии выражения получается _1_, мы обычно имеем в виду, что на практике попытка вычислить данное выражение приведет к ава­ рийному завершению программы с выдачей сообщения об ошиб­ ке или к зацикливанию программы. Атомом в FP является любая константа базового типа. На­ бор базовых типов, поддерживаемых FP-системой, произволен,

Другие стили ф у н к ц и о н а л ь н о го п р о гр а м м и р о ва н и я

105

но обычно он включает множество целых 0, 1, -—1, 2, —2 и т. д., символы, например 'a', 'b', 'с', булевы константы Т (true) и F (false), действительные числа, например 11.9, 3.56Е — 2, строки символов, например "Fred", «АТОМ» и "so on". Последовательность в F P —это симметричный список, не имеющий типа. Он симметричен в том смысле, что для каждой операции, работающей с началом (головой) последовательно­ сти, существует симметричная операция, работающая с концом (хвостом) последовательности. В отличие от Hope FP является нетипизированным языком, так что не существует ограничений на типы объектов, которые могут передаваться заданной функ­ ции, или на типы объектов, формирующих последовательность. Мы записываем последовательность как набор объектов, разде­ ленных запятыми, заключенный в угловые скобки, например: {) (1, 2) (( ), (1)} (1, 'а ', ( ))

обозначает пустую последовательность, сравните с n il в языке Норе обозначает последовательность чисел обозначает последовательность последовательностей обозначает последовательность объектов разных типов

Последовательности имеют очевидное сходство с S-выражения­ ми Лиспа, описанными в предыдущем разделе. Мы говорим, что последовательности в FP являются сохраняющими основание, имея в виду, что последовательность не определена, если любой или все ее компоненты являются неопределенными, т. е. = -L, если для любого i справедливо xi = ± ( 1 ^ i С п) Примитивы Как и в языке Норе, «примитивами» являются предварительно определенные функции, которые поддерживаются непосредствен­ но реализацией, а не определяются явно программистом. Набор примитивов, поддерживаемых FP-системой, произволен. «Ти­ пичный» набор примитивов (которые мы предполагаем суще­ ствующими здесь и далее) может быть таким, как в табл. 5.2. В FP мы обозначаем применение функции f к аргументу х с помощью символа :, т. е. fix Если функция имеет больше одного аргумента, эти аргумен­ ты передаются ей в форме последовательности. Так, если f — это функция от к аргументов, то применение f к аргументам Х),х2, . . . , хк записывается в виде f I , < , ... and, or, not, ... eqk, neqk, ... 1, 2, 3, ...

Арифметические операторы Примитивы, которые соответственно прибавляют к, вы ­ читают к и умножаю т на к заданный числовой аргу­ мент Операторы сравнения Булевы операторы И, И Л И , НЕ, ... Операторы сравнения с константой Функции-селекторы для последовательностей. Примене­ ние Т к последовательности (длиной по крайней мере i) дает i-й элемент последовательности, например: 3 : = х 3

lr , 2r, 3r, ...

Если последовательность имеет меньше i элементов, ре­ зультатом будет _1_. «Правые» функции-селекторы, определенные аналогично функциям 1, 2, 3, . . . , но выбирающие элемент, начи­ ная с правого конца последовательности, например: 2r : = x„_i

hd, tl

С тандартны е функции головы и хвоста последователь­ ностей hd : = xi tl : =

hr, tr

hd : х, tl : х = _L, если x не является последователь­ ностью П равые функции головы и хвоста: h r : = х„ tr : =

cons

hr : х, tr : x = _L, если х не является последователь­ ностью Конструктор списков (эквивалентный :: в языке Н оре), определенный в виде cons : =

null

consr : х = _1_, если х не является последовательностью из двух компонентов, первый из которых сам является последовательностью Функция, проверяющ ая, является ли последователь­ ность пустой (< ) ) , т. е. null : < ) = Т null : = F null : х = _L, если х не является последовательностью

Другие стили функционального программирования

10/

Продолжение табл. 5.2 distl

Ф ормирует из заданных объекта и последовательности последовательность пар: d i s t l : ,

, . . .

. . . . . . . . у„>,

х> = « y i ,

х>,

, . . .

. . . . id

Тождественная функция id : х = х, для всех х

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

+ := ( f : хь f : х2..........f : xn> Например, atl применяет функцию tail выделения хвоста после­ довательности к заданной последовательности последователь­ ностей: ( a t l ) : « l 2 3) ( 4 5 6>). Процесс называется редукцией в том смысле, что мы упрощаем выражение, убирая из него символ X, связанную переменную и выражение аргумента и получая измененную форму тела ^.-абстракции. (Терминология представ­ ляется несколько неудачной, так как если -выражение аргумента очень сложное и имеется много вхождений связанной перемен­ ной в теле абстракции, то результирующее выражение может быть намного длиннее первоначального, однако математически оно будет проще!) Редукция абстракции, примененной к некоторому аргументу, может дать в результате другую абстракцию, и в этом случае процесс может быть продолжен. Например, в следующем вы­ ражении: ( ( Х х . Х у . + х у )7) 8 мы начинаем с подстановки числа 7 вместо х в тело самой внешней абстракции (т. е. Ху + х у), получая (Ху. + 7 у) 8 и заканчиваем применением полученной абстракции к выраже­ нию аргумента 8, получая таким ообразом + 78 Данное выражение превращается в 15 с помощью 6-правила для функции + . Редуцируемое выражение называется редексом и мы на данном этапе можем заключить, что процесс редукции Я-выражения состоит из множества редукций, применяемых к редексам *> Слово редекс (re d e x )— аббревиатура от английского reducible expres­ sion (выражение, которое мож ет быть редуцировано). — Прим, перев.

126

Часть II. Глава 6

данного выражения до тех пор, noifa выражение включает в себя хотя бы один редекс. В дальнейшем мы поясним это утверждение и рассмотрим вопрос о порядке применения пра­ вил редукции. Мы будем иногда квалифицировать слово «ре­ декс» в соответствии с типом' правила, которое можно приме­ нить для упрощения данного выражения. Термин б-редекс поэтому соответствует выражению, которое может быть упрощено с помощью б-правила, а p-редексом на­ зывается выражение, которое можно упростить с помощью p-редукции. Так же как в случае б-редукции, мы не будем по­ мечать стрелку (-*-), обозначающую редукцию, символов Р в тех случаях, когда ясно, что упрощаемое (под) выражение является р-редексом. Теперь рассмотрим следующее ^.-выражение: Хх. ( Хх.х ) ( +1 х) Ясно, что х в теле внутренней абстракции ( Хх.х) и х в выра­ жении аргумента ( + 1х) — это разные переменные, имеющие одинаковое имя. Если'мы применим это выражение к аргументу, скажем 1, то следующее выполнение p-редукции будет ошибкой: ( Хх. ( Хх.х ) (+ 1 х )) 1 -*(Ал:.1)( + 1 1) -> 1 При выполнении p-редукции следует быть внимательными и не выполнять подстановку в тело абстракции, если связанная переменная этой абстракции имеет такое же имя, как и заме­ няемая переменная. В этом случае нужно оставить такую (внут­ реннюю) абстракцию без изменений. Возможно избежать кон­ фликта имен с помощью переименования переменных — в дан­ ном случае следует переименовать одну из переменных х, чтобы каждая переменная имела уникальное имя. Такое переименова­ ние называется a -преобразованием, и выражения, подвергнутые а-преобразованиЮ, т. е. эквивалентные с точностью до имен переменных, называются алфавитно-эквивалентными. Этот во­ прос будет рассмотрен в разд. 6.4. Чтобы показать, как работает Р-редукция, приведем сле­ дующий пример редукции выражения: (Xf . Xx. f 4 х) (Ху . Хх. -\-х у) 3 Шаг 1. Преобразуем единственный редекс, подставляя аргумент ( Ху.Хх. + х у ) вместо f в тело абстракции (Xf.Xx.f 4 х): -> (Хх .(Ху . Хх. -\-х у ) А л:)3

Математические основы: ^.-исчисление

127

Шаг 2. Произвольно выбрав редекс (один из двух возможных), подставляем аргумент 3 в тело абстракции ( ку.кх + х у )4х, но при этом оставляем без изменений внутреннюю абстракцию - - { к у . к х . + х у) 4 3 Шаг 3. Преобразуем единственный редекс, т. е. ( ку.кх. + х у )4: ->{кх. -\-х 4)3 Шаг 4. Преобразуем единственный редекс —*■+ 3 4 Шаг 5. Преобразуем полученный редекс с помощью б-правила для функции +• -+7 Заметим, что первая редукция заключается в подстановке вместо f функции ( ку.кх. + х у ) . Это вполне приемлемо; аб­ стракция, содержащая /, соответствует тому, что было бы функ­ цией высшего порядка в эквивалентной исходной программе. Важно заметить, что на шаге 2 у нас был выбор между двумя редексами и мы для преобразования произвольно взяли внешний редекс. Однако можно было бы применить р-редукцию и к внутреннему редексу, что привело бы к следующей цепочке преобразований: ( к х .( ку. кх. + х у) 4 х )3 (кх . ( к х . х 4 ) х ) 3 —>(кх. + х 4)3 (снова делаем произвольный выбор) —*■+ 3 4 - *7 Этот путь дает такой же результат и поэтому кажется вполне разумным, но в общем случае можно получить совершенно разное поведение для двух различных порядков выполнения редукций. Этот вопрос является предметом следующего раздела. 6 .3 . П о р я д о к р ед у к ц и й и н ор м ал ь н ы е ф орм ы

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

128

Часть II. Глава 6

программировании. Отсюда немедленно вытекает наивная схе­ ма вычислений: while существует хотя бы один редекс do преобразовать один из редексов end {выражение теперь в нормальной форме} Проблема, связанная с такой схемой, заключается в том, что в выражении может быть несколько редексов и непонятно, ка­ кой и них выбрать для преобразования. Чтобы увидеть, сколь важным может быть выбор, рассмотрим следующее выражение: (kx.kti.y)((kz.z z ) ( k z . z z)) Здесь два редекса: kz . z z ) ( k z . z z) и ( к х . к у , y ) ( ( k z . z z ) ( k z . z z ) )

(

Выбрав первый из них, получим следующую цепочку редукций: Xz.z z ) { k z . z z) - * ( k z . z z ) ( k z . z z) — ► ( k z .z z ) ( k z . z z )

(

которая никогда не закончится. Выбрав второй, получим ре­ дукцию, которая заканчивается за один шаг: (•' г .ку .у) ( ( k z . z z ) ( k z . z z ) ) - +ку. у Эти рассуждения приводят нас к рассмотрению порядка редук­ ций, определяющего в случае нескольких редексов, какой из них выбрать для преобразования. Вначале введем несколько определений: • Самым левым редексом называется редекс, символ к кото­ рого (или идентификатор примитивной функции в случае 6-редекса) текстуально расположен в ^-выражении левее всех остальных редексов. (Аналогично определяется самый правый редекс.) • Самым внешним редексом называется редекс, который не содержится внутри никакого другого редекса. • Самым внутренним редексом называется редекс, не содержа­ щий других редексов.

Математические основы: ^-исчисление

129

В контексте функциональных языков и Я-исчисления суще­ ствуют два важных порядка редукций, на которые мы будем постоянно ссылаться в этой книге: • Аппликативный порядок редукций1) (АПР), который предпи­ сывает вначале преобразовывать самый левый из самых вну­ тренних редексов. • Нормальный порядок редукций (НПР), который предписы­ вает вначале преобразовывать самый левый из самых внешних редексов. Возвращаясь к предыдущему примеру: (Хх. Ху, y ) ( ( Xz . z z ) ( X z . z z ) ) видим, что самым левым из самых внутренних редексов яв­ ляется ( Xz.z z ) ( X z . z z) а самым левым из самых внешних является ( Хх . Ху . у ) {( Xz. z z ) ( X z . z z ) ) При аппликативном порядке редукций первым будет вычис­ ляться редекс ( ( Xz.z z ) ( Xz.z z ) ) и вычисление никогда не за­ кончится, тогда как при нормальном порядке редукций вычис­ ление ^-выражения закончится за один шаг. Функция Хх.Ху.у — это классический пример функции, кото­ рая отбрасывает свой аргумент. НПР в таких случаях эффек­ тивно откладывает вычисление любых редексов внутри выра­ жения аргумента до тех пор, пока это возможно, в расчете на то, что такое вычисление может оказаться ненужным. Страте­ гия выбора самого левого из самых внешних редексов предпи­ сывает поэтому выполнять подстановку для х в Ху.у до любого преобразования выражения аргумента. В результате за один шаг получается нормальная форма Ху.у. АПР, с другой сто­ роны, вычисляет выражение аргумента в первую очередь, что в данном случае приводит к зацикливанию. Хотя отсюда сле­ дует, что мы должны всегда выбирать нормальный порядок ре­ дукций, чтобы гарантировать, что вычисление закончится вся­ кий раз, когда это возможно, АПР оказывается значительно более эффективным при реализации на обычных компьютерах, что мы и увидим далее в этой книге. Проницательный читатель уже мог обнаружить, что НПР и АПР соответствуют ленивому о Здесь под редукцией понимается (5- или б -р ед у к ц и я— элементарный ш аг преобразования Я-выражения к нормальной форме. М ож но говорить о редукции аппликативного (или нормального) порядка, понимая под редук­ цией процесс преобразования в целом. Мы будем использовать оба этих выраж ения как эквивалентные. В последнем случае слово «редукция» мы иногда будем зам енять словом «вычисление». — Прим, перев. 9



1473

130

Часть II. Глава 6

и энергичному вычислению, которые описаны в предыдущих главах, хотя в данном случае полной эквивалентности нет. В разд. 6.5.2 мы более детально рассмотрим это соответствие. 6.3.1. Две мощные теоремы В качестве небольшого отступления заметим, что мы гово­ рили о множестве различных порядков редукций выражения при неявном допущении: если вычисление закончится, то мы полу­ чим идентичные результаты при любом порядке редукций. Это весьма вольное допущение, но оно оказывается правильным. Более того, возможно также доказать, что НПР всегда приво­ дит выражение к нормальной форме, если нормальная форма существует. Эти два результата представлены в виде двух тео­ рем, называемых соответственно теоремой Черча — Россера и теоремой стандартизации: • Теорема Черча— Россера (следствие). Если выражение Е может быть приведено двумя различными способами к двум нормальным формам, то эти нормальные формы являются ал­ фавитно-эквивалентными. • Теорема стандартизации. Если выражение Е имеет нормаль­ ную форму, то редукция самого левого из самых внешних редексов на каждом этапе вычисления Е гарантирует достижение этой нормальной формы (с точностью до алфавитной эквива­ лентности). Доказательство этих теорем можно найти в [13]. Единственность нормальной формы выражения является следствием теоремы Черча — Россера. Сама теорема рассматри­ вает последовательности редукций вообще и ни в коем случае не ограничивается р- и б-редукциями. Ромбическое свойство отношения редукции-»-заключается в том, что если выражение Е может быть редуцировано к двум выражениям £1 и Е2, то существует выражение N, которое можно получить (повторно применяя ->-) как из £1, так и из £2. Это иллюстрируется следующей диаграммой:

£ £1

£2

N

Мы используем символ — * для обозначения произвольного числа, я ^ 0, редукций, т. е.->-* является рефлексивным тран­ зитивным замыканием отношения -*■. (Рефлексивность озна­

Математические основы: Х-исчисление

131

чает, что Е можно преобразовать само в себя, не делая ничего, т. е. за п = 0 шагов.) Формально ромбическое свойство можно записать так: £ - > * £ 1 и E - > * E 2 = > 3 N : E l - ^ * N и E 2- +* N Мы говорим, что отношение редукции —>- называется редук­ цией Черча — Россера, если отношение ->•* обладает ромбиче­ ским свойством, и доказательство, упомянутое выше, действи­ тельно демонстрирует, что p-редукция является редукцией Чер­ ч а — Россера. Теорема Черча — Россера обычно записывается в следующем виде: X cnv * У =>• 3 N : Х-> * N и Y - + * N где -> означает p-редукцию; a cnv— симметричное отношение р-преобразования: X cnv Y

X -+Y или У—>X

(Если в отношение -> наряду с p-редукцией мы включим 6-ре­ дукцию, наша система редукций может в некоторых паталогических случаях не обладать ромбическим свойством. Тем не ме­ нее так называемое слабое ромбическое свойство имеет место. Оно отличается от ромбического свойства тем, что редукции Е-*~Е 1 и Е^ ~Е 2 должны быть одношаговыми, т. е. звездочки у верхних стрелок в диаграмме опускаются, и формально запи­ сывается средующим образом: Е - + Е \ и Е~* E2=>3N : E l - * * N и E 2 ^ * N Говорят, что отношение редукции является слабой редукцией Черча — Россера, если оно обладает слабым ромбическим свой­ ством. Однако ни одна из константных функций, используемых нами, не нарушает ромбическое свойство.) Сама теорема Черча — Россера ничего не говорит нам о единственности нормальной формы выражения. Однако допу­ стим, что М и N — две различные нормальные формы одного и того же выражения Е, т. е. мы имеем Е->* М и Е~>* N Применим теорему Черча — Россера: 3 Z ' . M - * * Z и N —> * Z

Но так как М и N — это нормальные формы, возможен един­ ственный вариант: M s * N s* Z 9*

152

Часть II. Глава 6

т. е. нормальная форма Е действительно единственна. (Мы ис­ пользуем символ вместо —, так как речь идет об алфавит­ ной эквивалентности выражений.) 6.4. р-Редукция и проблема конфликта имен Рассмотрим следующее выражение: Хх. Ху. х у у Мы говорим, что переменная х является связанной в данном выражении, так как оно содержит Хх. Эквивалентно мы говорим, что имеет место связанное вхождение переменной х в данное выражение. Теперь рассмотрим тело самой внешней абстракции: Ку.х у у Хотя х входит в данное выражение, это вхождение не является связанным, так как выражение не содержит Хх. Поэтому мы говорим, что х является свободной переменной в данном выра­ жении. Однако переменная у по-прежнему связана благодаря Ху. Если мы рассмотрим теперь тело самой внутренней абстрак­ ции, т. е. х У У

то здесь как х, так и у являются свободными. Мы будем иногда обозначать множество свободных переменных выражения Ё че­ рез FV(E), например: FV{ Хх.Ху.х у у) = { } FV( Xx. x у у) = {у) FV( x у у) — {х, у} FV можно определить более формально следующим образом: FV( k) = 0 k — это константа FV( x) = {х} х — это переменная FV(E 1 E2) = FV{E\ ) \ }FV{E2) FV(Xx. E) = F V { E ) — x где множество S — х получается из множества S удалением всех вхождений элемента х. А,-Выражение Е, не содержащее свободных переменных (т. е. F V ( E ) — 0 ) , называется замк­ нутым. Используя представления о свободных и связанных пере­ менных, мы можем теперь формализовать определение p-редук­ ции: ^-редукция — это такое правило преобразования выраже­ ния (Хх.Е) А, которое дает модифицированную форму Ё, где

Математические основы: Я-исчисление

133

все вхождения свободной в Е переменной х заменены на А. К сожалению, существует проблема, связанная с р-редукцией, которую можно проиллюстрировать на следующем примере: Хх. ((Ху.Хх, + * у ) х ) Это выражение содержит единственный p-редекс, выделенный подчеркиванием. Если мы попытаемся вычислить этот редекс, то получим Хх.(Хх -\-х х) что, очевидно, является ошибкой. Проблема состоит в том, что в этом примере выражение аргумента содержит свободную переменную, имеющую одина­ ковое имя с одной из связанных переменных в теле абстрак­ ции, т. е.

ЭТОТ X С о п о ап

Этот х с в о б о д в и

Чтобы безопасно выполнять p-редукцию, необходимо сначала модифицировать абстракцию так, чтобы сделать все имена свя­ занных переменных уникальными по отношению к свободным переменным в выражении аргумента. В нашем примере поэтому нужно переименовать связанную переменную х в теле абстрак­ ции, скажем в х': ( Ху. Хх. +х у ) - у ( Ху . Хх ' . +х ' у) Теперь выполнение p-редукции безопасно: Хх. ((Ху. Хх' . + х ' у ) х ) —>- Хх. ( Хх' . А-х' х) что совершенно правильно. Этот процесс переименования называется а-преобразоваиием, или a -подстановкой. Он основан на той идее, что два та­ ких выражения, как Хх.х и Ху.у обозначают одну и ту же функцию, так как отличаются только именами связанных переменных, другими словами, являются алфавитно-эквивалентными. Мы говорим, что выражение Хх.х может быть a -преобразовано в Ху.у, или что Хх.х и Ху.у яв­ ляются a -преобразуемыми, и записываем

134

Часть И. Глава 6

В общем случае выражение Х х . Е может быть преобразовано в выражение Х х ' .Е ’, где Е ' получается- из Е заменой всех вхож­ дений свободной переменной х на х ', при условии, что x f сама не является свободной переменной в Е . Например, следующее a -преобразование корректно: Xx.f х y ^ a Xz.f z у

А вот пример неправильного а-преобразования: %X . f X y ^ a ^ y . f

у у

Проблема конфликта имен накладывает ограничения на р-редукцию: мы можем безопасно выполнить p-редукцию выраже­ ния £1 Е 2 , только если ни одно имя свободной в Е 2 переменной не совпадает с именами связанных в Е \ переменных [43]. Пе­ реименование переменных в Е 1 является потенциально трудо­ емкой операцией, и мы будем стараться избегать ее при реали­ зации процесса редукции на компьютере. Одно из решений проблемы заключается в принятии соглашения об именах пере­ менных [13]. Второе решение заключается в том, чтобы не вы­ полнять Р-редукцию в случае присутствия свободных перемен­ ных. Это предпочтительный подход, так как он требует изменить только наше представление о нормальной форме, а не о схеме редукции или схеме наименования переменных. Этот подход К решению проблемы конфликта имен будет рассмотрен в разд. 6.5. 6 .4 .1 . П р а в и л о

т] - п р е о б р а з о в а н и я

В дополнение к а- и p-преобразованиям существует третье правило, называемое правилом ^-преобразования. Это правило основано на том, что выражения Хх.Ех и Е обозначают одну и ту же функцию при условии, что ляется свободной переменной в Е , так как

х

не яв­

( Х х . Е х ) А -*-Е А

для любого выражения А . Это называется функциональной экстенсивностью. Мы ришем Х х • Е х -> ЧЕ имея в виду, что выражение Х х . Е х т)-редуцируется в выражение Мы говорим также, что Х х . Е х является туредексом и назы­ ваем это преобразование туредукцией, так как подразумеваем упрощение выражения слева от стрелки. Однако можно запи­ Е.

Математические основы: Х-исчисление

135

сать преобразование по-другому, используя символ — как в случае a -преобразования. В этом случае говорят, что два вы­ ражения являются ^-преобразуемыми. Обычно мы будем использовать термины «p-редукция» и «т]-редукция», но не будем классифицировать а-преобразование как редукцию, так как оно не упрощает структуру выражения. Однако мы также оставляем за собой право использовать тер­ мин p-преобразование, как, например, при рассмотрении тео­ ремы Черча — Россера в разд. 6.3.1. X и Y являются р-преобразуемыми, если или Х->-рУ, или У-»-рХ; в последнем случае мы иногда будем говорить, что У получено из X с помощью р-абстракции. Хотя в этой книге мы ссылаемся на rj-редукцию, мы не бу­ дем касаться вопроса о встраивании механизма т)-редукции в какой-либо процессор Х-выражений, так как такое преобразо­ вание при необходимости можно сделать при компиляции. Сле­ довательно, при использовании слова «редекс» в контексте вы­ числений мы будем обычно подразумевать p-редекс или 6-редекс, если явно не оговорено противное. 6.5. Обход проблемы конфликта имен В разд. 6.4 мы познакомились с проблемой конфликта имен, которая может возникнуть при p-редукции, когда выражение аргумента содержит свободные переменные, имеющие одинако­ вые имена со связанными переменными тела применяемой абстракции. Эта проблема может возникнуть всякий раз, когда мы пытаемся преобразовать выражение в его нор­ мальную форму (т. е. форму без редексов). Например, выра­ жение Хх. ( ( Ху.Хх. + х у ) х ) не находится в нормальной форме, и перед проведением р-редукции необходимо а-преобразование. • Оказывается, однако, что проблему можно решить без по­ мощи a -преобразования, определив ограниченную нормальную форму, называемую слабой заголовочной нормальной формой. Выражение Е находится в слабой заголовочной нормальной форме (СЗНФ), если: (1) Е является константой, (2) Е является выражением вида Хх.Е' для любого Е', (3) Е имеет форму РЕ{Е2 ... Еп для любой константной функ­ ции Р арности k > п. Третье правило утверждает, что любая частично применен­ ная константная функция также является СЗНФ. Это вполне

136

Часть II. Глава 6

разумно, так как можно переписать выражение вроде *3 используя правило ^-преобразования, в выражение Хх. * 3 х которое находится в СЗНФ. Заметим также, что выражение, состоящее из единственной переменной, является СЗНФ, но здесь мы рассматриваем только замкнутые выражения. Преимущество приведения выражений к СЗНФ вместо пол­ ной нормальной формы заключается в том, что мы избегаем необходимости применять p-редукцию в присутствии свободных переменных. Мы можем встретить свободные переменные толь­ ко в случае, если «пройдем через X», так как все ссылки на пе­ ременную, стоящую после X, будут свободными только справа от «.». Останавливаясь перед X, мы не входим в тело функции и, следовательно, не можем встретить свободные переменные вообще. Используя предыдущий пример снова: Хх.( ( Ху .Хх + У х ) х ) мы не пытаемся вычислить подчеркнутый редекс, так как выра­ жение в целом находится в СЗНФ. Только когда выражение применяется к аргументу, вычисление имеет место, и это вы­ числение начнется с удаления самой левой связанной перемен­ ной х в соответствии с правилами редукции нормального по­ рядка. Таким образом, мы заменим порождающее проблему вхождение свободной переменной х в (Ху.Хх -f- у х ) х значением аргумента (которое также не может содержать свободных пе­ ременных), и проблема конфликта имен никогда не возникнет. Например: (Хх. {(Ху. Хх. + у х ) х ) ) 4 - > (Ху.Хх. + У х ) 4 ->Хх. + 4 х Заметим, что в промежутке между нормальной формой и СЗНФ расположена так называемая заголовочная нормальная форма (ЗНФ). Говорят, что выражение Е находится в ЗНФ, если: (1) Е является константой, (2) Е является выражением вида Хх\.Хх2 ... Ххп.Е', где Е' не является редексом, (3) Е имеет форму РЕХЕ2 ... Еп для любой константной функ­ ции Р арности k~> п. Это определение включено для полноты. Приведение к ЗНФ обычно не практикуется, так как при этом

Математические основы: Х-исчисление

137

не удается избежать проблемы конфликта имен,/ Используем наш пример снова: Хх .((Ху .Хх + у х ) х ) Выражение не является ЗНФ, так как ( Ху.Хх. + у х ) х — это редекс. Поэтому будет выполнена внутренняя (5-редукция и воз­ никнет конфликт имен. Заметим, что все ЗНФ являются одновременно СЗНФ, но обратное неверно. Например, Хх.+ 23 — это СЗНФ, но не ЗНФ, так как + 2 3 является редексом. 6.6. Эффект разделения Рассмотрим следующее выражение: ( Хх. + х х ) Е Применив к нему правило p-редукции, получим + Е Е Чтобы преобразовать это выражение, мы должны сначала пре­ образовать аргументы функции + , а это означает, что выра­ жение Е должно быть редуцировано дважды. Понятно, что та­ кое вычисление очень неэффективно. Один способ, гарантирующий, что аргумент никогда не бу­ дет вычисляться более одного раза, заключается в вычислении его перед выполнением p-редукции. Это соответствует аппликативному порядку редукций. Вместо подстановки аргумента в исходном виде выполняется подстановка значения аргумента, поэтому повторные вхождения связанной переменной не при­ водят к повторной редукции аргумента. Если, однако, мы хо­ тим реализовать нормальный порядок редукций, то должны каким-то образом сделать так, чтобы первое вычисленное (т. е. приведенное к СЗНФ) выражение аргумента «разделилось» между всеми остальными выражениями этого аргумента, т. е. заменило бы каждое из них своим значением. В таком случае будем говорить, что выражение аргумента является разделяемым. Здесь мы отходим от модели редукции, предлагаемой в Х-исчислении, где вычисление выражений записывается в чи­ сто текстовой форме. Это означает, что можно записывать про­ межуточные выражения, формирующие последовательность ре­ дукций, с помощью набора символов на обычном листе бумаги (такой процесс иногда называют редукцией строк). Если мы хотим изучить эффект разделения, то должны выразить факт, что два вхождения одного и того же идентификатора относятся к одному и тому же выражению как-то иначе, нежели в случае

138

Часть II. Глава 6

копирования этого выражения дважды. Один из способов сде­ лать это состоит в представлении выражений в виде графов. Это приводит нас к идее редукции графов, которая будет де­ тально описана в гл. 11. Здесь, однако, будем придерживаться следующего соглашения. Имея выражение вида ( Ях. . . . х . . . х . . . ) Е результат (3-редукции этого выражения будем записывать в виде . . . х . . . х . . . , где х есть Е, чтобы обозначить, что оба вхождения х в результирующем выражении относятся к Е. Связь между именами переменных и их значениями обычно называется контекстом, и мы говорим, что механизм редукции является основанным на контексте, а не на копировании, как в Я-исчислении. Мы более подробно рас­ смотрим контексты в гл. 9. Смысл такого подхода в том, что когда х (т. е. Е) вычис­ ляется первый раз, редуцированная форма Е заменяет не толь­ ко х в выражении, но и £ в контексте этого выражения. И когда нам в следующий раз потребуется вычислить х, он будет немедленно заменен редуцированной формой Е. На­ пример: ( Ях. —> + —> + ->+ ->6

+ х х ) ( + 1 2) х х где х есть ( + 1 2 ) 3 х где х есть 3 3 3

и мы видим, что выражение аргумента (+ 1 2 ) вычисляется только один раз. 6.6.1. Схемы редукции и механизмы вызова Теперь, когда введено понятие разделения, можно сопоста­ вить различные возможные стратегии при вычислении Я-выражений с механизмами вызова. Этот термин часто используется, когда речь идет о языках программирования высокого уровня. Механизм вызова определяет, как передаются параметры при вызове процедуры или функции. Он поэтому тесно связан с на­ шим предыдущим обсуждением порядка редукций и эффекта разделения.

Математические основы: Я-исчисление

139

Имея выражение вида { к х . Е\ ) Е2 мы знаем, что один способ упростить его заключается в вычис­ лении выражения аргумента Е2 в первую очередь и в выпол­ нении затем p-редукции, подставляющей значение Е2 вместо х в Е 1. Этот способ соответствует аппликативному порядку вы­ полнения редукций и эквивалентен вызову по значению в тра­ диционных языках программирования. В функциональных язы­ ках программирования вызов по значению известен как энергичное вычисление, о котором мы уже говорили в гл. 4 и которое в ^.-исчислении мы можем определить следующим об­ разом: Энергичное вычисление = АПР, приводящий выражение к СЗНФ В качестве альтернативы АПР можем сначала подставить Е2 вместо х в £1, что соответствует нормальному порядку ре­ дукций. В Я-исчислении, где отсутствует понятие разделения, такая подстановка является чисто текстовой. Она поэтому со­ ответствует понятию вызова по текстовой замене. Как мы уже видели, при этом может возникнуть конфликт имен и необхо­ димость повторного вычисления выражений аргументов. Если мы обходим проблему конфликта имен (например, выполнив «-преобразование для каждого выражения аргумента или при­ водя выражение только к СЗНФ), тогда такой механизм со­ ответствует вызову по имени. При этом, однако, мы не можем избежать повторного вычисления аргументов. Если мы реализуем нормальный порядок редукций и подщрживаем разделение (гарантируя, что аргументы вычисляют­ ся не более одного раза), тогда механизм редукции соответ­ ствует вызову по необходимости. В традиционных языках вы­ зов по имени и вызов по необходимости отличаются, только если выражение аргумента дает побочные эффекты. В функ­ циональном языке не существует побочных эффектов, и, следо­ вательно, два этих механизма вызова дают всегда одинаковый результат. Вызов по необходимости поэтому можем охаракте­ ризовать как нормальный порядок редукций, приводящий выра­ жение к СЗНФ вместе с разделением выражений аргументов. Если соединим вызов по необходимости с ленивыми конструк­ торами, то получим ленивое вычисление, с которым уже встре­ чались в гл. 4. Ленивым называется конструктор, аргументы которого остаются невычисленными, т. е. не приведенными К СЗНФ. Следовательно, описать ленивое вычисление значения

140

Часть II. Глава 6

Х-выражения можем следующим образом: Ленивое вычисление = НПР, приводящий выражение к СЗНФ + разделение + ленивые конструкторы или эквивалентно: Ленивое вычисление = вызов по необходимости + ленивые конструкторы Фактически конструкторы (за исключением, может быть, предварительно определенных конструкторов, таких, как кон­ структор списков CONS) обычно реализованы как кортежи (см. гл. 8). Поэтому применения функции-конструкторов трансли­ руются в вызовы предварительно определенной функций TUPLE-n, приведенной в табл. 6.1. Это означает, что строгость конструкторов определяется строгостью функции TUPLE-n. Именно по этой причине ленивые конструкторы указаны от­ дельно в определении ленивого вычисления. Стандартная реа­ лизация языка Норе, в котором смешаны энергичное и ленивое вычисление, может быть определена следующим образом: АПР, приводящий выражение к СЗНФ + ленивые конструкторы Это соответствует вызовам функции TUPLE-n по необходи­ мости, а всех остальных функций по значению, хотя следует быть осторожными при обработке условных выражений. Мы рассмотрим эту тему более детально в гл. 9. 6.7. Рекурсивные выражения До сих пор мы рассматривали представление только нере­ курсивных функций в Х-исчислении. В языках программирова­ ния высокого уровня, однако, необходимо иметь возможность записывать определение рекурсивных функций. Мы можем вы­ разить рекурсию в языке высокого уровня, так как имеем воз­ можность дать имя каждой функции, используемой в программе. На такие имена можно ссылаться где угодно в программе (при определенных ограничениях, обусловленных языком), даже в теле функции, названной тем именем, на которое ссылаемся. Теперь рассмотрим проблему рекурсивных функций в Х-исчислении, где функции не имеют имени. Поэтому для представ­ ления рекурсии необходимо придумать метод, позволяющий функциям вызывать себя не по имени, а каким-то другим обра­ зом. Другой (гораздо менее очевидный) взгляд на рекурсию

Математические основы: ^-исчисление

141

состоит в том, чтобы представить рекурсивную функцию как функцию, имеющую самое себя в качестве аргумента. В этом случае функция может оказаться связанной с одной из ее соб­ ственных переменных и будет, таким образом, содержать в своем теле ссылки на самое себя. Рассмотрим, например, рекурсивную функцию sum, опреде­ ляемую на языке Норе следующим образом: --------sum( n ) < = If п = О then 0 else n + sum( п — 1); Это выражение может быть представлено в виде Я-абстракции, имеющей дополнительный параметр, который при применении этой абстракции связывается с самой функцией. Мы запишем эту промежуточную версию функции SUM: SUM = Xs.hn.COND{ = n 0 ) 0 ( + f t ( s ( — га 1))) Все, что нам осталось сделать теперь, — это связать перемен­ ную s со значением функции sum, которую пытаемся опреде­ лить. Это можно сделать, использовав специальную функцию, называемую У-комбинатором, которая удовлетворяет следую­ щему уравнению: Yf = f(Y f) У также известен как комбинатор фиксированной точки. «Фик­ сированная точка» функции f — это выражение, которое не из­ меняется при применении к нему функции f. (Заметим, что функция может иметь несколько фиксированных точек. Например, функция тождества Хх.х, имеет бесконечное их число.) Выражение Yf дает наименьшую фиксированную точку функции /. Чтобы понять смысл этого термина, мы должны «погрузиться глубже» в теорию функций. В этой книге не тре­ буется детальное понимание данного вопроса, но для заинтере­ сованного читателя мы включили приложение Б, в котором дано краткое руководство по теории доменов. Знакомство с приложением Б поможет понять смысл термина, а более де­ тальное обсуждение предмета можно найти в [75]. Посмотрим теперь, что получится при применении У к функ­ ции SUM, приведенной выше:У У SUM = У( Ях. Яга.COND{ —га 0 ) 0 ( + r a ( s ( —га I ) ) ) ) ->(Xs.Xti.COND(=n 0 ) 0 ( + r a ( s ( —га 1) ) ) ) ( У SUM) - * ( Я n. C O N D (= n 0 ) 0 ( + л < ( У SU M )( —га 1)) ))

142

Часть II. Глава 6

Это оказалось тем, что нам нужно. Чтобы убедиться в этом, распишем внутреннее выражение (У SUM) подобным образом: - > ( Kti.COND ( = п 0) 0 ( + п ( ( Ы . С О М В ( = п 0 ) 0 ( + п ( ( У SUM)(—п 1)))) ( - « 1)))) Мы видим, что данное выражение ведет себя точно так же, как исходное рекурсивное определение sum. Внутреннее вхождение YSUM конструирует копию исходной функции SUM, поме­ щая само себя (т. е. УSUM) вместо s в тело копии. Это то, что нам нужно. Таким образом, функция sum выражается в Л-исчислении в виде У SUM, т. е. У ( %s .Xn.COND ( = п 0 ) 0 ( + л ( « ( — п 1) ) ) ) В общем случае рекурсивная функция f с телом, задаваемым выражением Е, записывается в Л-исчислении в виде УЩ.Е) 6.7.1. Взаимная рекурсия В общем случае можно определить набор взаимно рекур­ сивных функций, таких, как --------« . . . ) < = £ , ; -------- Ы . . . ) < = Е2;

-------- / „ ( . . . ) < = £ » ; где Ei может ссылаться на любую (или на все сразу) из функ­ ций //( 1 i, j ^ . n ) . Чтобы реализовать такие определения, мы используем так называемое кортежирование. Идея заклю­ чается в том, чтобы упаковать набор из п взаимно рекурсивных функций в n-кортеж и затем транслировать ссылки на /,• в ин­ декс I-го элемента такого кортежа. Для реализации этой идеи необходимо использовать константные функции TUPLE-n и INDEX, приведенные в табл. 6.1 и определяемые следующим образом: TUPLE-n Е{Е2 . . . Еп строит Ei, Е2, сывать INDEX k ( E it . . . . En) = Ek

кортеж, состоящий из выражений . . . , Е п, который мы будем запи­ в виде: ( Е ,, Е2, . . . , £ „ ) (1(кх.ку.х)(кх.ку.у)(кх.ку.у) -+(ку.(кх.ку.у))(кх.ку.у) -+(кх.ку.у) = FALSE 6 .8 .2 .

Списки в

ч и ст о м к - и с ч и с л е н и и

Теперь, имея представления для булевых констант T R U E и можем использовать их для определения списков. Обычно списки строятся с помощью двух функций-конструк­ торов, одна из которых обозначает пустой список ( N I L ) , а дру­ гая строит новый список из элемента и старого списка ( C O N S ) . Язык Норе, например, использует для этих целей nil и инфикс­ ный оператор : : . Чтобы представить C O N S в чистом Л-исчислении-, следует рассматривать выражение

FALSE,

CONS h t

как функцию, берущую в качестве одного из ,своих аргументов функцию-селектор и применяющую ее к выражениям для го­ ловы ( h ) и хвоста (/) списка. Следовательно, CONS =

kh.kt.ks.s h t

где переменные h, t, s соответствуют голове списка, хвосту списка и функции-селектору. Селекторы — это такие функции, которые возвращают либо первый, либо второй аргумент функции-конструктора. Поэтому они могут быть представлены в виде следующих Х-выражений: k h .k t.h и k h .k t.t что в точности соответствует определениям T R U E и F A L S E , приведенным выше. Это означает, что функции для выделения головного и хвостового элементов списка L (т. е. H D и T L со­ ответственно) могут быть определены следующим образом: HD = kL.L TL =

TRUE

k L .L F A L SE

Математические основы: Х-исчисление

147

Применение функций HD или TL к выражению CONS а b дает в результате а или b соответственно. В этом легко убедиться: HD{CONS а Ь) = (Хс .с TRUE) ((Xh.Xt.Xs.s h t ) a b) —»• ( ( Xh.Xt.Xs.s h t ) a b)TRUE —>( Xs. s a b ) TRUE TRUE a b = (Xh.Xt .h) a b -> (Xt.a) b —a Последней функцией обработки списков, которую мы опре­ делим в терминах чистого ^-исчисления, будет функция IE, которая при применении к пустому списку (т. е. NIL) возвра­ щает TRUE. Определение этой функции приводит нас к удоб­ ному представлению пустого списка. Начнем ^с рассмотрения выражения IE ( CONS а Ь) IE ( CONS а Ъ) , = IE ((Xh.Xt.Xs.s h t ) a b) - * I E (Xs.s a b) Так как мы ожидаем, что это выражение дает FALSE для лю­ бых значений а и Ь, ясно, что это выражение для функции IE должно иметь вид 1Е = Хс. с( Xh.Xt.FALSE) Отсюда естественным образом мы приходим к выражению для пустого списка, определяемому таким образом, чтобы IE х да­ вало TRUE, если x — NIL, и FALSE в противном случае: NIL = Хх.TRUE В качестве примера приведем последовательность редукций для выражения IE (TL ( CONS 1 NIL ) ): IE ( TL( CONS 1 NI L) ) = (Xc. c(Xh. Xt . FALSE))(TL(CONS 1 NI L) ) -> (TL( CONS 1 NI L) ) (Xh.Xt. FALSE) = ((Xc. c FALS E) ( CONS 1 NI L) ) (Xh.Xt .FALSE) -+((CONS 1 NI L) FALSE) (Xh. Xt. FALSE) ^ ( ( ( X h . X t . X s . s h t) 1 N I L ) F AL SE) ( Xh. Xt. FALSE) - >(((Xt. Xs. s 1 t) NI L) FALSE) (Xh.Xt. FALSE) -+((Xs.s 1 NI L) FALSE) (Xh.Xt. FALSE) ( FALSE 1 NI L) (Xh.Xt. FALSE) = ( ( Xx. Xy. y) 1 NI L) (Xh.Xt. FALSE) 10*

148

Часть И. Глава 6

—> (( Х у . у ) N I L ) ( X h . X t . F A L S E ) —>■N I L ( X h . X t . F A L S E ) -*•(/*• T R U E ) ( X h . X t . F A L S E ) -* TRUE 6 .8 .3 . Н а т у р а л ь н ы е ч и с л а в ч и ст ом Х - и с ч и с л е н и и

Используя введенное выше представление списков и рас­ сматривая число п как n-элементный список объектов, имею­ щих произвольные значения, можно теперь определить нату­ ральные числа. Число 0 в этом случае соответствует пустому списку ( N I L ) , и, аналогично, функции E Q 0 и P R E D соответ­ ствуют функциям обработки списков I E и T L . Функция S U C C , возвращающая следующий за п элемент натурального ряда, т. е. прибавляющая единицу к данному числу п , должна только расширить на один элемент представляющий п список. По­ этому она определяется следующим образом: S U C C — Х п . C O N S "any" п

где

" a n y " — это любое /-выражение. Существует множество представлений для таких объектов, как булевы константы, натуральные числа и списки; представ­ ления, приведенные выше, ни в коем случае не являются един­ ственными. Альтернативную модель для натуральных чисел можно найти, например, в [13]. Важно отметить, что мы нашли путь для представления натуральных чисел и операций S U C C , P R E D и E Q 0 . Так как мы имеем также У-комбинатор (описан­ ный в разд. 6.7), то можем реализовать любую рекурсивную функцию, что вытекает из теории рекурсии [67]. Таким обра­ зом, хотя материал этого раздела представляет только акаде­ мический интерес, с учетом остального содержания данной книги он дает пример выразительной силы /-исчисления, даже когда казавшиеся поначалу такими важными б-правила отсут­ ствуют.

6 .9 . /.-и сч и сл ен и е д е Б р е й н а

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

Математические основы: ^.-исчисление

149

этими целыми числами, и из самих символов Я. При этом се­ мантически эквивалентные ^.-выражения являются также син­ таксически эквивалентными. Целое число, заменяющее пере­ менную, можно рассматривать как «смещение» или «глубину вложенности» вводящего эту переменную символа Я, после ко­ торого в этом случае нет никакой необходимости указывать имя переменной. В качестве примера приведем представление в Я-исчислении де Брейна функции «следующий за» ( SUCC ): Я. + L 0 1 Более сложное Я-выражение Ях.Яу.Я/./(Я^.х)( + х у) имеет следующее каноническое представление: Я. Я. Я . 1 0 ( Я . 1 0 ) ( + L2 Ы ) Поскольку в каноническом Я-исчислении нет имен перемен­ ных, там нет эквивалента правилу a -преобразования выраже­ ний, и это ставит вопрос о возможности выполнения (3-редукции в присутствии свободных переменных. Оказывается, можно вы­ полнять преобразование, эквивалентное Р-редукции в Я-исчислении (будем называть его р*-редукцией), без каких-либо яв­ ных требований переименования. Но правило р*-редукции яв­ ляется более сложным по сравнению с правилом р-редукции, так как оно перенумеровывает канонические переменные в теле функции, даже если эти переменные не нужно замещать; это во многом похоже на ос-преобразование. Интересное свойство Р*-редукции в присутствии свободных переменных состоит в том, что такое переименование (перенумерация) связанных переменных с целью сделать их имена уникальными по отно­ шению к свободным переменным аргумента становится фор­ мальным алгоритмом (фактически частью самого правила р*редукции) в Я-исчислении де Брейна. Детальное описание пра­ вила р*-редукции можно найти в [89]. При рассмотрении редукции Я-выражений использование представления де Брейна дает немногим больше, чем формаль­ ное определение правила переименования. Тем не менее нота­ ция де Брейна используется в схеме реализации, называемой категориальной абстрак?ной машиной, которую мы рассмот­ рим в гл. 13. Резюме • Я-Исчисление — это исчисление безымянных функций. Оно включает в себя нотацию для записи выражений и набор пра­ вил преобразования этих выражений.

150

Часть II. Глава 6

• Правилами преобразования Я-исчисления являются «-преоб­ разование, которое соответствует переименованию, р-преобразование, соответствующее применению функции, и ^-преобра­ зование, соответствующее функциональной экстенсивности. • Я-Исчисление может быть также дополнено произвольным набором констант, таких, как целые числа, и связанных с ними функций, называемых б-правилами. • Подвыражение, которое может быть преобразовано с по­ мощью одного из правил преобразования или одного из б-правил, называется выражением, которое может быть редуциро­ вано, или редексом; выражение, не имеющее редексов, назы­ вается нормальной формой. • Когда есть возможность выбрать редекс для преобразова­ ния, выбор определяется порядком редукций. Двумя альтерна­ тивными стратегиями выбора являются аппликативный порядок редукций и нормальный порядок редукций, тесно связанные с энергичным вычислением и ленивым вычислением соот­ ветственно. • Нормальный порядок редукций гарантирует завершение пре­ образования, если это возможно. Так утверждает теорема стан­ дартизации. • Если две различные последовательности редукций приводят выражение к двум нормальным формам, то эти нормальные формы эквивалентны с точностью до имен переменных. Таково следствие теоремы Черча — Россера. • Рекурсивные функции могут быть выражены в Я-исчислении с помощью специальной функции, называемой У-комбинатором, которая находит наименьшую фиксированную точку функции. • Удалив б-правила, мы получим чистое Я-исчисление. В нем можно выразить любые функции, несмотря на отсутствие б-правил. • В Я-исчислении де Брейна все имена переменных заменены целыми числами, обозначающими глубину вложенности сим­ вола Я, связывающего соответствующую переменную; р-преобразование в этом исчислении выполняется с помощью правил Р*-редукции. Упражнения 6.1. Укажите связанные и свободные переменные в каждом из следующих Я-выражений: а) (Хх. х у ) ( 1 у . у ) б) Яx . X y . z ( k z . z ( k x . у)) в) ( Кх. ку. х z ( y z ) ) ( X x . y ( k y . y ) )

Математические основы: ^.-исчисление

151

6.2. Для каждого из следующих выражений: (i) Xx. Xy. ( Xz. z ) x ( + у 1) (ii) ( Xx. Xy. x( Xz . y z ) ) ( { ( X x . X y . y ) 8 ) ( X x . ( X y . y ) x ) ) (iii) ( X h . ( X x . h( x x ) ) ( X x ,h(x x ) ) ) (( Xx . x ) ( +

1 5))

а) подчеркните все |3-, тр и 6-редексы; б) найдите самый левый из самых внешних и самый левый из самых внутренних редексов; в) найдите нормальную форму и слабую заголовочную нор­ мальную форму, используя нормальный порядок редукций. 6.3. а. Рассмотрим выражение F = ( ХТ.Т Т ) ( Xf.Xx.f ( f x ) ) . По­ кажите, как проявляется конфликт (имен при приведении этого выражения к нормальной форме с помощью нормального по­ рядка редукций. б. Покажите, что FsuccO, где succ = Xx. + х 1, приводится к 4 при нормальном порядке редукций и при приведении к СЗНФ. 6.4. Рассмотрите следующее выражение: Хх. ( Ху. у) ? а. Покажите, что если при p-редукции оно замещает не­ сколько вхождений переменной, то редекс ( Ху.у) 7 будет вы­ числяться каждый раз при применении данного выражения, даже если механизм редукции основан на контексте (подразу­ мевается приведение к СЗНФ с использованием НПР). В этом смысле НПР является неоптимальным. б. Можете ли вы предложить модификацию НПР, гаранти­ рующую, что такие редексы будут вычисляться не более одного раза. 6.5. а. Приведите пример функции, имеющей: (i) много фиксированных точек; (и) ровно одну фиксированную точку. б. Что является наименьшей фиксированной точкой выраже­ ния Хх.*хх? Каковы его остальные фиксированные точки? в. Объясните решение упражнения 6.2.в в случае выраже­ ния (iii). 6.6. Рассмотрим две взаимно рекурсивные функции языка Норе: f( п ) < = if n = 0 then 0 else g(n) ; --------g ( n ) < = n + f ( n — 1); которые имеют эквивалентное выражение Х-исчисления: У ( XT.TUPLE-2 (Xti.COND ( = п 0 )0 { ( I N D E X 2 Т ) п ) ) ( Х п . + n ( ( I N D E X 1 Т ) ( — п 1)) ))

152

Часть И. Глава 6

Покажите последовательность вычислений выражения f ( l ) . 6.7. а. Для предложенного в разд. 6.8.1 представления булевых величин TRUE и FALSE в чистом Л-исчислении (i) покажите, что TRUE AND TRUE = TRUE, (ii) определите NOT и EXCLUSIVE-OR (исключающее или). б. Для предложенного в разд. 6.8.3 представления целых чисел в чистом ^-исчислении определите функцию PLUS (плюс). в. Целое число я > 0 в чистом ^-исчислении можно опреде­ лить следующим образом: Я'= Х х . Х у . х ( х ( . . . х ( у ) . . . )) Определите для этого представления функцию SUCC. 6.8. Покажите представления де Брейна для следующих Я,-выражений: а) Х х . Х у . у ( X z . z х ) х б) к х . ( к х . х х ) ( Х у . y ( X z . x ) ) в) ( Х х . + х ( ( Х у . у ) ( — х ( Х г . З ) ( Х у . у у ) ) ) )

Глава 7 СИСТЕМА ВЫВОДА ТИПОВ И ПРОВЕРКА ТИПОВ

В первой части этой книги говорилось о роли строго типи­ зированных функциональных языков в процессе разработки про­ грамм. В таких языках задачи решаются в терминах отображе­ ний на множестве сложных объектов данных, имеющих струк­ туру, соответствующую логике решения, а не компьютеру, на котором выполняется программа. Кроме того, понятие полимор­ физма (введенное в гл. 2) позволяет определять родовые функ­ ции, которые могут выполнять одинаковые операции с данными разного типа, причем конкретный тип данных получается в ре­ зультате приписывания значений переменным типа, входящим в некоторый полиморфный тип. Альтернатива полиморфизму в строго типизированных языках состоит в определении отдель­ ной версии функции для каждого типа данных, к которому она применима. При этом тела в определениях таких функций бу­ дут одинаковыми. Более того, если реализация типизированного языка обеспечивает проверку типов на этапе компиляции, нет никакой необходимости хранить информацию о типах на этапе выполнения программы, так как можно считать, что созданный компилятором код не содержит ошибок, связанных с типами данных. Это означает, например, что функции всегда приме­ няются к аргументам правильного типа. Таким образом, проверка типов важна по двум причинам. Во-первых, она позволяет выявить множество логических оши­ бок программы еще на этапе компиляции. Многие логические ошибки являются результатом несоответствия типов выраже­ ниям, так что запускать программу для выявления этих ошибок просто нет необходимости. Во-вторых, как мы только что заме­ тили, программа на этапе выполнения работает гораздо более эффективно, так как всякое упоминание о типах данных может полностью отсутствовать в коде, создаваемом компилятором. В разд. 7.1 на двух примерах будут проиллюстрированы ос­ новные проблемы вывода типов функциональных выражений и

154

Часть II. Глава 7

определены типы выражений путем решения системы уравне­ ний. Каждое уравнение представляет собой ограничение, накла­ дываемое на тип некоторого подвыражения рассматриваемых выражений. Процедура вывода наиболее общего типа выраже­ ния может рассматриваться как доказательство в достаточно простой системе логического вывода, имеющей небольшое число правил. В разд. 7.2 мы рассмотрим такую систему. Хотя фор­ мальное описание вывода типа является строгим и полным в том смысле, что если выражение имеет тип, то его можно вывести, остается неясным вопрос о выборе нужной последовательности шагов доказательства и, следовательно, о том, как автоматизи­ ровать процедуру. Схемы проверки типов являются алгорит­ мами, выводящими наиболее общие типы выражений детерминированно, и их можно рассматривать как руководства по по­ строению доказательств в системе вывода типов. Другими словами, схемы проверки типов можно рассматри­ вать как доказатели теоремы. Они обычно включают в себя отождествление пар выражений типа и реализуются с помощью алгоритма отождествления Робинсона [73], который находит наиболее общий унификатор (подстановку для переменных типа) пары выражений. Поэтому мы можем быть уверены, что такая схема находит действительно наиболее общий тип; это можно доказать частично с помощью результатов Робинсона. В разд. 7.3 мы рассмотрим самый первый алгоритм проверки типов Ж [66J, который достаточно прост и обладает важными свойствами обоснованности и полноты. В разд. 7.4 мы покажем, как можно расширить W, чтобы он мог проверять тип функций, определен­ ных с помощью сопоставления с образцом. В последнем разделе данной главы мы рассмотрим циклически определенные типы, которые нельзя вывести с помощью W. 7.1. Неформальное введение в проверку типов Тип — это или переменная типа, которую мы будем обозна­ чать какой-либо греческой буквой, или применение оператора типа к определенному числу аргументов типа. Базовые типы, такие, как пит (целый тип) и truval (булев тип), являются операторами типа без аргументов, a list — это оператор типа с одним аргументом. Мы также используем бинарные инфикс­ ные операторы (оператор типа функции, эквивалентный-> в языке Норе) и X (оператор типа декартова произведения, эквивалентный =#= в языке Норе). Типы, содержащие по крайней мере одну переменную, являются полиморфными (политипами), а типы, не содержащие переменных типа, являются мономорф-

Система вывода типов и , проверка типов

155

ными (м о н о т и п а м и ). Таким образом, базовые типы являются монотипами, так же как и типы многих примитивных функций, например su .c c : п и т ^ - п и т и i s - z e r o : п и т - * - t r u v a l . Однако не все примитивные функции являются мономорфными, например h d ' . l i s t а-» -а является полиморфной, так как ее определение типа содержит переменную типа а. Конечно, можно определить произвольно сложные выражения типа, например выражение типа для функции т а р может быть записано следующим обра­ зом: (а -> ji)X l i s t a - * - l i s t |J. Можно было бы ввести в рассмот­ рение произвольное число базовых типов, но нам вполне доста­ точно п и т , t r u v a l и l i s t , чтобы объяснить рассматриваемые принципы. При проверке типа выражения мы хотим найти наиболее об­ щий тип, который оно может иметь, в том смысле, что все мономорфные типы, которые это выражение может принимать, т. е. типы, получаемые при присваивании переменным типа конкрет­ ных значений, являются частными случаями этого наиболее об­ щего типа. Другими словами, нам необходимо сконструировать систему вывода типов, которая могла бы или определить наи­ более общий тип, или прийти к противоречию, которое означает, что выражение не имеет наиболее общего типа. Если исходная программа содержит пользовательские объявления типов, как это может быть в языке Норе, тогда последующая проверка типов необходима, чтобы гарантировать, что объявленный тип совместим с выведенным типом. Для этого нужно, чтобы поль­ зовательский тип можно было получить из выведенного типа с помощью подстановки определенных значений вместо пере­ менных типа. Это может потребовать больше работы, чем при прямом выводе, но для языков сопоставления с образцом, где функция может быть определена с помощью нескольких урав­ нений, выведенный тип каждого уравнения может быть прове­ рен на соответствие объявленному типу. Такая проверка, вы­ полненная независимо для каждого уравнения, может потенци­ ально быть более эффективной, чем соответствующий вывод типа, который отождествляет типы, выводимые из каждого урав­ нения, чтобы получить наиболее общий тип. 7 .1 .1 . Д в а н е ф о р м а л ь н ы х в ы в о д а типа

Тип функционального выражения часто может быть выведен эвристическими методами, и в этом разделе мы рассмотрим два примера: применение тождественной функции к числу 3 и по­ лиморфную функцию т а р . В частности, мы увидим, что поли­ типы, ассоциированные с le t -св я зан н ы м и переменными (т. е. с переменными, введенными в квалифицированных выражениях),

156

Часть II. Глава 7

которые называем родовыми, должны обрабатываться совер­ шенно не так, как политипы, ассоциированные с неродовыми Х-связанными переменными, если мы не должны допускать при­ сваивания неправильного типа. Основную идею можно понять, рассматривая применение функции f к выражению е. Если мы знаем, что е имеет тип а, то можем немедленно сделать вывод, что тип f — это о —>-р, где р — тип, который должен быть выведен. Следовательно, мы мо­ жем рекурсивно выводить типы чисто аппликативных выраже­ ний; при этом самые внутренние выражения с известными типами обеспечивают базовые случаи. Если включить в рассмот­ рение квалифицированные выражения, ситуация становится бо­ лее сложной, так как тип присваивается идентификатору дваж­ ды (в квалификаторе и в результанте), и поэтому мы должны отождествить такие типы, чтобы получить наиболее общий тип. Рассмотрим для начала выражение Е вида let fx = х In f 3 которое записано в форме функционального «псевдокода». Что­ бы имело место согласование между типами его подвыражений, требуется выполнение уравнений, приведенных ниже, в которых мы обозначаем тип идентификатора ide символом olde, а неиз­ вестный тип символом р, (i > 0): pi Рг Рг -> Рз Рг

(базовый тип) (f 3 — это применение функции) (тип х неизвестен) (fx — это применение функции) (две части равенства = долж ны иметь одинаковый

тип)

(1) (2) (3) (4) (5)

(Мы опустили уравнение для подвыражения х в fx. Оно имело бы вид Ох = Р4 , и затем мы получили бы a f = p 4-*-p3. Но из двух уравнений для ох мы могли бы заключить, что р2 = р4.) Из этих уравнений можно видеть, что р2 = пит, pi = рз (из уравнений (2) и (4)), р3 = р2 (уравнение (5)), что в резуль­ тате дает pi = р2 = рз = пит, и типом выражения /3 является Pi = пит, как и ожидалось. Тот факт, что система уравнений имеет решение, говорит о корректности выражения Е с точки зрения его типа. Аналогичные рассуждения справедливы для полиморфных выражений. Рассмотрим следующую версию функции тар, вы­ раженной не через сопоставление с образцом, а с помощью при­ митивных функций-селекторов null, nil, cons, hd и tl: map(f, m ) = if nullfm) then nil else cons(f (hd(m)), map(f, il (m)))

Система вывода типов и проверка типов

157

Поскольку примитивные функции обработки списков яв­ ляются полиморфными, мы присваиваем новые неизестные типы ть Т2 , ... переменным типа этих функций. Эти переменные типа являются родовыми, и разным их вхождениям мы при­ сваиваем разные неизвестные типы, хотя в данном примере су­ ществует только одно вхождение каждой переменной. Начнем со следующих присваиваний: апин — list т, -> truval anU = list т2 list т4 (Уcons = ( Т5 X list т5) ► list т 5 Продолжая, как в предыдущем примере, запишем теперь уравнения, гарантирующие соответствие типов каждого подвы­ ражения в определении тар. Начиная слева направо, получим следующи^-уравнения: &тйр = Of / v @т — Pi Для некоторой неизвестной переменной типа Pj ( 1) (2 ) ^7lull = ат —► truval &hd ~~= Р4 ан ==а т Рз &тар = °f X Рз - ► Р5 ^cons = Р4 X Рб Рб

(3) (4) (5) (6 ) (7)

Наконец нам нужно уравнение, гарантирующее, что обе ветви условного выражения имеют одинаковый тип, который является типом всего условного выражения, и уравнение, тре­ бующее, чтобы обе части равенства в определении тар имели одинаковый тип. Вместе эти уравнения имеют вид Pi = °n ll — Рб-

(8)

Эта система из восьми уравнений вместе с уравнениями для типов примитивных функций в общем случае может быть решена с помощью основанного на отождествлении алгоритма, который мы рассмотрим в разд. 7.3. Здесь же мы решим эту систему с помощью простых логических рассуждений. Из уравнения (2) и уравнения для null имеем am = list Т[ а из уравнения (3) и уравнения для hd имеем ат = list т3 и р2= т3

158

Часть И. Глава 7

Подобным образом из уравнений (4) — (7) получим 9z = Gm = Ust т4 Р з = стт и p 5 = Pi (сравнивая два уравнения для < ттар) Р

4=

Р

5= list

Tg

list

Tg

Рб —

т

5

Используя эти уравнения и уравнение' (8), получим решение: Pi = р5 = р5 =

list т 2 — Ust~T5 (поэтому т 2 = т 5) (используя уравнения для о т )

р2= = т 1= т3 = т 4 р3=

om = list т,

Р4 = = т 5 =

т 2

Наконец, получаем Of = р2- * р 4 , поэтому отар = ( Т| ->- х2) X X list Ti — list %2 ‘ Так как типы ti и х2 являются произвольными, заключаем, что полиморфный тип тар имеет, как и ожидалось, вид (а-*

list a- * list р

7.1.2. Родовые переменные типа Рассмотренные примеры не доставили нам особых трудно­ стей, и теперь рассмотрим функцию g, имеющую вид £ = Л /.(/3 , ftrue) Тип этого выражения не может быть определен, так как мы не знаем полиморфные характеристики A-связанной переменной f. Первое вхождение / требует, чтобы ее тип был пит-*-а, тогда как второе вхождение требует, чтобы тип / был truval^>-a. Пе­ ременные типа, относящиеся к A-связанному идентификатору, такому, как /, не являются родовыми, поскольку, как можем видеть в этом примере, они относятся к каждому вхождению f в теле функции, и некоторые значения этих переменных могут вызвать конфликт. Если бы мы дали g тип ( а-»-Р )-»-( Р X Р ). выражение могло бы быть вычислено правильно в случае при­ менения к определенным аргументам, таким, как Ах.О, что дает в результате (0,0). Однако функция succ («следующий за») имеет тип, который соответствует а -> р и является поэтому до­ пустимым аргументом для g, но ее применение к аргументу true приведет к ошибке. Поэтому данный тип выражения g в общем случае неверен, и мы говорим, что выражение g не может иметь

Система вывода типов и проверка типов

159

тип. В действительности расширение Милнера (Milner) системы типов, которое мы рассмотрим в данной главе, дает возможность определить тип g, но этот вопрос выходит за рамки нашей книги. Внешне похожим примером является выражение let f — Xx.x in ( / 3, / true) тип которого мы можем определить, используя полиморфизм. Это действительно так, поскольку теперь / является let-связан­ ным идентификатором, локальным в выражении, так что мы точно знаем, как он определен, и можем использовать эту ин­ формацию в каждом отдельном случае его вхождения. Здесь / имеет тип а —>-а, который может принять вид nutria пит, когда / применяется к аргументу 3, и truval^- truval, когда f приме­ няется к аргументу true. В первом примере данного раздела тип / зависит от типа аргумента, к которому применяется вы­ ражение g, и поскольку мы заранее не знаем тип каждого та­ кого аргумента, то не можем заранее определить тип /. Пере­ менная типа, такая, как а, входящая в выражение типа let-свя­ занного идентификатора, называется родовой и может получать различные значения для различных вхождений идентификатора при условии, если она не входит одновременно в выражение типа ^-связанного идентификатора, область действия которого в выражении включает область действия let-связанного иденти­ фикатора. Если мы попытаемся решить проблему, с которой столкнулись при рассмотрении первого примера, с помощью вы­ ражения %g. let f = g in ( f 3, flrue) у нас ничего не получится. Снова применение данного выраже­ ния к функции succ даст ошибку, так как любая переменная в типе / теперь не является родовой, поскольку входит в тип Х-связанного идентификатора g. В заключение можно сказать, что переменная типа, входя­ щая в тип выражения Е, является родовой, если и только если она не входит в тип идентификатора связанной переменной лю­ бой Х-абстракции, для которой Е является подвыражением. Перед тем как в следующем разделе формально определить систему вывода типов, покажем, как обрабатываются рекурсив­ ные квалифицированные выражения. Мы будем делать это с по­ мощью оператора фиксированной точки fix (представляемого в Х-исчислении в виде У-комбинатора), обрабатывая объявле­ ния вида let / = . . . f . . . in . . . / . . .

160

Часть II. Глава 7

как если бы определение f было расширено для получения не­ рекурсивной формы следующим образом: let f = fix /.

in .. . f . . .

где iixx.e можно рассматривать как YXx.e, хотя для целей вы­ вода типов fix не обязательно должен давать наименьшую фик­ сированную точку. Таким образом, полный синтаксис выражений, для которых мы будем выводить типы в следующих двух разделах, имеет вид (exp) ::=(id) | if (exp) then (exp) else (exp) | X (id).(exp) \ (exp) (exp) | let (id) = (exp) in (exp) | fix (id).(exp) (id) ::= идентификатор 7.2. Система вывода типов В предыдущем обсуждении мы немного сократили нотацию для записи выражений типа, опустив квантор «для любого», обозначаемый символом V. Например, когда мы записываем а —>-|3, то на самом деле имеем в виду Va.V|3.a->-(3. В системе типов Милнера, на которой основана эта глава, все переменные типа связаны кванторами общности на верхнем уровне; кван­ торы не могут быть введены внутрь выражений типа. Таким об­ разом, мы будем иметь дело исключительно с так называемыми поверхностными типами, т. е. такими, которые имеют вид Va! . . . Va„T (п 1^0), и выражение типа т не содержит кван­ торов. Система вывода допускает типы, не являющиеся поверх­ ностными, однако алгоритма проверки типа, который мог бы работать с ними, у нас нет. Теперь, поскольку все типы являют­ ся поверхностными, мы можем опустить кванторы, считая их заданными неявно. Однако мы будем помнить о них, так как они хорошо проясняют разницу между родовыми переменными типа (которые соответствуют свободным переменным в выраже­ нии, содержащем кванторы) и неродовыми переменными типа (которые соответствуют переменным, связанным кванторами). Приведем здесь восемь правил вывода (см. [20]), первое из которых является аксиомой, а остальные — это действительно правила вывода. Запись А (—е : г означает, что из набора допу­ щений А мы можем сделать вывод о том, что выражение е имеет тип т (символ 1— называется крестовиной). Допущение — это соединение типа т и переменной х, обозначаемое х : т. Запись А . х : х обозначает набор допущений, полученный из набора А присоединением к нему допущения х : х (сам набор А не содер­ жит такого допущения).

Система вывода типов и проверка типов

161

Запись А В

читается «из А мы можем вывести В » . Наконец, запись [о /а]т означает подстановку а вместо всех свободных вхождений а в выражение типа т (предполагается, что а не входит в зону действия связанной квантором общности переменной с име­ нем о). Правила имеют следующий вид: П еременны е А . х : х (- х : х

[VAR]

У с л о в н ы е вы р а ж е н и я А \ - е : tru va l

А (- е ' : т

А \ - е” : т

[COND] А )- ( i f е the n е ' else е " ) : т Абстракции А . х : а I- е : т ------------------------------А | - ( Лх.е ): а -> т

[[ A B S ]

А \-е :о ^ -х А ~ > е ':а П рименения А \- ( е е ' ) : т

[АРР\

А \- е ':а А .х : о е:х L e t-выражения ---------------------------------------А | - ( le t х = е' in е ) : т А . х : х \- е : х Фиксированная точка ------------------------^4 |— ( f i x х . е ) : х А (- е : х

[LET]

[F/.K]

О б о б щ е н и е -------------------- (а не является свободной в А ) А (- е : V a .x

А )- е : У а . т Специализация -------------------А е : [а / а ] т

[G EN]

[SP.EC]

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

—х : a

1

[F.-4/?]

\—( Х х . х ): a -»■а

[ЛЙ5]*1

1— ( A x . x ) : V a . a —> а

[G E N ]

11



1473

162

Часть II. Глава 7

Специализированный тип функции тождества можно получить с помощью [SPPC ]: Ь- {Х х .х )'. V a .a —>■a —( Хх. х ) 1пит -* пит [SPEC]

1

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

х : пит

[VЛР]

I—{ Х х . х ) : пит—>пит [ЛЙ5] Можно вывести, что типом выражения ( Хх.х) 3 является пит: 3 : пит, х : пит Ь- х : пит [VЛ/?] 3 : пит Ь- [ Х х . х ) : пит->■пит [ЛВ5]

3 : пит I—3 : пит [УЛР]

3 : пит 1- ( ( Хх.х ) 3 ) : пит [АРР] В действительности эта система способна выводить типы для выражений, с которыми наш алгоритм справиться не в силах. Простейшим примером является выражение {Хх.хх), включаю­ щее в себя запрещенное самоприменение хх. Обозначив тип V a.a-»-а через ф, получим x\h-x: ф[УAR] х : ф \ - х :ф -> ф [SPEC]

х : ф \ - х : ф [КЛР]

х : Ф I—х х : ф

[АРР]

\ - { Х х . х х):ф ^> ф [ЛВ5] Не рассматривая первый «эвристический» шаг х :ф \~ х:ф, можно считать ключевым шагом данного доказательства ис­ пользование правила [SPPC], представляющего вместо а вы­ ражение Va.a->-а, что дает тип, не являющийся поверхност­ ным. Давайте теперь вернемся к нашему предыдущему при­ меру let / = Хх.х \ n ( f 3 , f true). Будет удобно для целей нашей дискуссии представить кортеж ( f 3 , f t r u e ) как результат приме­ нения функции tuple-2 (имеющей тип a->-|3->-aX Р ). введен­ ной в гл. 6 . Это объясняется тем, что мы пока не имеем правил о Этот алгоритм описан в следующем разделе. — Прим, перев.

Система вывода типов и проверка типов

163

вывода для кортежей. Перепишем поэтому наше выражение в следующем виде: let f = Xx.x in tuple-2 (f 3 ) ( f true) Предположим, что набор допущений А = { 3 : пит, true : truval, tuple-2 : V a.V p.a->-p-»-aX Р } и пит A.f •ф

A . f : Ф 1—3 : пит

A . f : ф I—f 3 : пит f ■ф

A .f : ф У- f truval -у truval

A . f :ф Ь- true : truval

A . f : ф I—/ true '■truval A I—tuple-2 : Va. V p.a->p -> a X P A 1—tuple-2 VP. num —►p —>пит X P A I- tuple-2 : num- * truval->пит X truval

A .f : ф h- / 3 : num

A . f : фУ- tuple-2 ( f 3): truval —>пит X truval A . f :ф1- f true : truval A . f ф \—tuple-2 ( / 3) ( f true): пит X truval Наконец, используя результат для функции тождества, по­ лучим А У- Хх.х'.ф A . f : ф 1- tuple-2 ( f 3) ( f true): пит X truval Л [ - ( le t f = Kx. x In tuple-2 ( f S) ( f true)): питУ, truval Однако при определении типа эквивалентного выражения ( Я/.tuple-2 ( / 3) ( / true) ) ( Хх.х) невозможно вывести поверх­ ностный тип для Xf.tuple-2(f3) ( ft ru e ). Тип этого выражения не является поверхностным и имеет вид (Va.Vp.a->-p)->->-(Уу.Уб.уХ б ). Его можно получить с помощью аналогичного доказательства, начав с допущения f:a->-§. Теперь ясно видно отличие между родовыми и неродовыми переменными типа. Если переменная входит в тип Х-связанного идентификатора, она должна входить в набор допущений для того, чтобы можно было последовательно применять правило [Л В 5]. Следовательно, переменная является родовой, если она п*

164

Часть II. Глава 7

не фигурирует в наборе допущений, вследствие чего мы можем применить правило [GZriV] и связать ее квантором V. Поэтому существует прямая связь между родовыми переменными и кван­ торами общности. Конечно, не все этапы приведенных выше доказательств яв­ ляются очевидными a priori, и для практической проверки ти­ пов необходимо автоматизировать процедуру доказательства в рассматриваемой системе вывода. Фактически это означает, что необходимо построить алгоритм проверки типа выражения, определяющий порядок применения правил вывода. Такой ал­ горитм можно рассматривать как эвристику доказательства. Су­ ществует фЬрмальный способ установления соотношения между описанной выше системой вывода и алгоритмами проверки типа, в частности, можно доказать, что если с помощью алгоритма найден тип какого-либо выражения, то этот тип можно выве­ сти в описанной системе вывода. В деталях этот вопрос рас­ смотрен Милнером в [6 6 ]. 7.3. Алгоритм проверки типа W При разработке функции для вывода типа, о которой чаще говорят как об алгоритме для проверки типа, возникает не­ сколько синтаксических и семантических вопросов, требующих рассмотрения. Во-первых, нужно определить синтаксическую схему типов, которая присваивает уникальный (наиболее об­ щий) тип каждому синтаксически допустимому выражению. Та­ кие выражения называются правильно типизированными, а их типы называются правильными типами. Во-вторых, необходимо показать, что схема типов является семантически правильной, т. е. все синтаксически правильные выражения не содержат се­ мантического несоответствия типов. Так, необходимо быть уве­ ренными, что при вычислении правильно типизированного вы­ ражения все его примитивные функции будут применяться к ар­ гументам соответствующих типов. В-третьих, алгоритм должен быть синтаксически правильным. Это означает, что если с по­ мощью данного алгоритма найден тип выражения, то это вы­ ражение является правильно типизированным. Наконец, хоте­ лось бы также, чтобы алгоритм проверки типов был полным в том смысле, что если выражение имеет правильный тип, то алгоритм находил бы его по крайней мере в большинстве слу­ чаев. В данной книге мы не рассматриваем теоретические ас­ пекты перечисленных выше вопросов, отсылая читателя к ра­ боте [6 6 ]. Наша версия алгоритма Ж является упрощенным вариантом алгоритма Милнера и тесно связана с системой вы­

Система вывода типов и проверка типов

165

вода, описанной в предыдущем разделе. Мы запишем алгоритм проверки типов в виде функции. Ж вычисляет наиболее общий тип выражения, если поверх­ ностный правильный тип существует. Он основан на алгоритме отождествления Робинсона. Теорема 7.1 (см. [73]) Существует алгоритм У, имеющий на входе любую пару вы­ ражений а, х (над некоторым алфавитом переменных), такой, что или У (а, т) дает в результате подстановку U, удовлетво­ ряющую условиям: (1) U o = Ux, т. е. U отождествляет о и т; (2) если R отождествляет ст и х, то для некоторой подстановки S имеет место R = SU; (3) U затрагивает только переменные, входящие в а и т; или У(о,х) заканчивается неудачей. Когда У завершается успешно, именно свойство (2) подста­ новки U гарантирует, что U — это наиболее общий унификатор. В нашем случае выражениями являются выражения типа, а ал­ фавит— это множество переменных типа. Поэтому, например, У (а, а) дает в результате подстановку U = I (тождественную подстановку), а У (а -* -13, пит -*- пит) дает подстановку U = = [пит/а, пи/п/р], где подстановка выражения а,- вместо пере­ менной а,- ( l ^ t ' ^ n ) обозначается [oi/ai, . . . , crn/oс„]. Однако y(a-*-truval, п и т п и т ) заканчивается неудачей. Для полноты определим сейчас функцию, реализующую У и основанную на идее несогласованной пары выражений типа. Говоря неформально, это означает, что для данных термов е, е' (в нашем случае это выражение типа) несогласованная пара D ( e , e ' ) содержит первые два подтерма в е н е ' соответственно, которые различаются между собой; если е = е', оба компонента пары пусты, и мы обозначаем такую пару символом л = ( , ). Например, D { n u m ^ n u m , a -+ num ) = (num,a),D(a,ct-+-$) = = (a , a-> -p), D(nu m^ ~ пит, n u m ^ - n u m ) = n, D(y-+-num->-$, (a пит )->~ пит ) = (у, a - ^ n u m ) . Заметим, что в послед­ нем случае выражения типа не могут быть отождествлены. Проще всего дать точное определение несогласованной пары в форме функции. Предположим, что каждое выражение типа записано в виде Ti(o\, . . . , a„(l)) ( i ^ l ) , где Г,- обозначает операцию типа арности n(i), а а,- обозначает выражение типа ( / ^ 1 ) . Так, например, мы записываем a-^-(p X v ) в виде - > ( а, Х ( Р л ) ) > 3 базовый тип, такой, как пит, арности О, в виде пит.

166

Часть II. Глава 7

Наша функция несогласованной пары имеет вид D(Ti(au Tj( ть ...,тп(П)) = if Т , Ф Т , t h e n ( T i { a u . . . , Стл. { i ) ) , Г / ( т ь . . . , т м п ) ) e ls e if n ( i ) = 0 then я e ls e D ' ( 1)

where

D'

( k ) = if k = n { i ) then D ( a k, x k ) else if D ( a k, %k ) = n then D ' { k + \ ) else D { a k, x k )

Функция отождествления У : терм X те рм -> п о д с т а н о в к а может быть определена на основе вспомогательной функции : п о д с т а н о в к а X те р м X те рм -> п о д с т а н о в к а

unify

следующим образом: = u n i f y ( I , е, е ' ) = if S e = S e ' then S else let (и, v ) — D ( S e , S e ' ) in if и — это переменная, не входящая в v then u n i f y { [v j u ] S , e, e ' ) else if v —это переменная, не входящая в и then u n i f y ( [ u / v ] S , e , e ' ) else НЕУДАЧА

У (е, е ' ) u n i f y ( S , е, е ' )

Композиция двух подстановок S и Т обозначается S T ; на­ пример, ( [о /ы ]5 )т является термом, получаемым заменой пе­ ременной и на терм v в терме S x . Мы опускаем определения функций, проверяющих, являются ли и или v переменными и входят ли и или v соответственно в v или и. Если ни и, ни v не являются переменными, тогда отождествление заканчивается неудачей, но, согласно данному определению, оно закончится неудачей также, если и — это переменная, входящая в v (о в этом случае не может быть переменной) или и — это перемен­ ная, входящая в и. В каждой из этих ситуаций существует воз­ можность циклической подстановки, приводящей к зациклива­ нию алгоритма, поэтому отождествление прекращается. Эта простая проверка возможного зацикливания называется про­ веркой вхождения. Проверка вхождения предотвращает зацик­

Система вывода типов и. проверка типов

167

ливание при отождествлении, но она может привести к тому, что некоторые унификаторы не будут найдены, хотя на самом деле они существуют (другими словами, циклическая подста­ новка не имеет места). Поэтому существует определенное коли­ чество допустимых циклических типов, которые не могут быть выведены с помощью реализации Ж, использующей данный ал­ горитм. Позднее мы еще вернемся к этому вопросу. Рассмотрим работу описанной выше функции на следующем простом примере: У (а-^Р, Р—>■у) =

unify(I,

а-*Р, Р->-у)

= unify([Wa],

а^-р, Р->у)

так как D (a-* p , Р~>у) = (а, Р) = u n i f y ( [ у/Р ] [ р/ a ], а-> р , Р->у) так как 0 ( [ p /a ] ( a - * P ) , [ P/a ] ( р -* у )) = D ( р р , р -> у ) = (Р> Y) = [Y/P][P/a] так как [ у/Р ] [ P/a] ( а — Р ) = [ у/Р ] [ р/а] ( Р-»■ у ) = Y-^У= [ У/Р, Р/а] Вернемся теперь к алгоритму Ж. Имея набор допущений А (присваивающих типы переменным, как в предыдущем раз­ деле) и выражение е, мы в случае успешного завершения ал­ горитма Ж получим Ж{ А, е ) = ( Т, т ), где т — это наиболее об­ щий тип е, а Т — это подстановка, такая, что ТА определяет соответствующие типы переменных из А. Фактически Т возвра­ щается только как часть результата, что дает возможность про­ должать рекурсивное применение Ж. Ниже показано, как вы­ глядит полный «алгоритм» Ж( А , е) = (Т, т)

где (a) Если е — это идентификатор х, то Т — 1, и если jc:Vai . . . ... а„.б е А, то * = [ PiM ] • • • [ Рл/ап ] а где {р, | 1 г=: i ^ п) — это новые переменные типа. (b) Если е = f g , пусть (Я, Р ) =

П А ,

f)

( S, а ) = Ж{ RA, g ) U = r{S p,

a-p)

168

Часть II. Глава 7

где р — это новая переменная типа. Тогда Т — USR и т = Uр. (c) Если е = if р then / else пусть (R, р) = Ж’(А, р) U — Y(p, truval) (S, а ) = W( URA, f ) (S', о') = Ж( SURA, f ') U' — T(S 'a, a') Тогда T = U'S'SUR и т = U'a'. (d) Если e = Xx.f, пусть (R, p) = W ( A . x : p, f ) где p — новая переменная типа, U = Y ( R $ , p ) . Тогда T = (e) Если e — fixx.f, пусть (R, p) = W ( A . x : p, f ) где p — новая переменная типа, U = Y ( R § , p ). Тогда T — = UR и т = UR$. (f) Если e = let x = / in g, пусть (R, p ) = W ( A , f ) (S, a ) = W ( R A . x : p', g ) где p' = Vai ... a n.p и ai, . . . , an — это свободные переменные в р, не входящие в RA. Тогда Т = SR и т = о. Вспоминая рассмотренную в предыдущем разделе взаимо­ связь между родовыми переменными и кванторами общности, мы можем видеть, что в случае (а) новые переменные типа р,заменяют только родовые переменные в типе х, которые по­ этому могут быть заменены независимо от их вхождений в дру­ гие типы; это соответствует применению правила вывода [SPEC], Напротив, в случае (f) все свободные переменные в типе х, не встречающиеся в наборе допущений RA, связаны квантором общности, поскольку являются родовыми; это соот­ ветствует немедленному применению правила вывода [ GEN ] столько раз, сколько возможно. Примеры, иллюстрирующие работу алгоритма Ж, очень уто­ мительны, и мы рассмотрим только главные шаги алгоритма для двух случаев: во-первых, для функции, уже использовав­ шейся нами при демонстрации различия между родовыми и не­ родовыми переменными в разд. 7.2, и, во-вторых, для рекурсив­ ной функции, находящей длину списка. Данные примеры затра­

Система вывода типов и проверка типов

169

гивают все шесть случаев описанного выше алгоритма. Все символы р с индексами или без будут обозначать новые пере­ менные типа. Мы будем использовать два различных результата для типа единичной переменной, соответствующих неродовому и родовому случаям: Ж( А .х : а, х ) = ( /, а) Ж( А.х.: а, х ) = (1, р ) Оба они следуют из (а), и из (d) мы немедленно получаем Ж(А, Xx.x) = (R, (/?р)-> р) где (R, p) = F ( А .х : р , * ) = ( / , р ) = (/, р -> р )

Теперь рассмотрим выражение e = let f = k x .x in tuple-2 ( f 3 ) ( f true) и набор допущений Л = { 3 : пит, true'.truval, tuple-2: e ^ 6 - > - s X 5 } Ж(А, e) = ( S lRl, как мы т о л ь к о (Si, ox) = W ( A a, tuple-2 ( f 3 ) ( f true)) где Aa — A . f : V a .a -> a с о г л а с н о (f) —

Таким образом, повторно применяя (b), мы получаем (Si, P2) ( ^ 2> P 2 ) —

(

U2 S 3 R 3 , U 2 P 3 )

где ( R 3, Рз ) = ЗГ(Ла, tuple-2 )= = (/, e -> 6 - (> X S )) (S 3, a3) — Ж(1Аа, (f 3 )) U2 = У( S3p3, 0 3 —>Рз) ( S3, o3) — ( U^S^Ri, U3P4) где ( R 4, Pi ) = F ( 4 , f ) — ( I , Э5 —►Pb) (S 4, а4) = Г ( / Л а, 3) = (/, пит) Uz == У ( S4p4, р3) = [пит/в, б —>■( пит X R2= ££2 ^ 3 ( так как Кз — П> Рз = ^ —*•( X б) Подобным образом (S 2, o2) ^ ( U 4S5R5, £/4 рб) где (/?в, р5) = Г (/?И а, / ) = (Л Р 7 - Р 7 ) ( 5з, 0 Г5) = (/, /гыаа/)

6

)/р3]

£ /4 = У !’( р 7 - > р 7, (гaval - > Р6 ) = [ tru v a ljfi7, t r u v a l/M

так что S2= U4, а2= truval Таким образом, Ux = T { 6 - > { n u m y 6 ) , truval-> р2 ) = [truval/b, (numX,truval)/$2] и Si — UJJ4UJJ3, а х— питУ, truval, Rx — I

так что Ж{А, e) = (S 1, п и т У truval) Заметим, что как и при рассмотрении системы вывода в преды­ дущем разделе, родовая переменная а в типе f заменяется раз­ личными типами Рб и р7, которые в процессе отождествления превращаются в пит и truval соответственно. Для нашего следующего примера запишем в нерекурсивной форме, которая удобна для применения алгоритма Ж, функцию length, находящую длину списка: length = 1ix f. kx. e, где e = if null x then 0 else succ(f(tl x ) ) Набор допущений, который мы используем, имеет вид А = {null: list а, —>truval, t l l i s t а2 —> list щ, 0 : пит, succ : пит — >пит} Тогда на основании случая (е) алгоритма Ж имеем Ж( А, length ) — { U0Rx, U0Rxр , ) где (Rx, рх) = Ж ( А .! : Р,, Хх.е) U0= r(RxVx, Pi)

Система вывода типов и проверка типов

171

Тогда (Я „ Рх) = ( /?2» ЯгРг “♦Рг) на основании (d) где ( R 2, Р2 ) = ^ ( ^ 2 . е )> где ^ 2 обозначает A .f : .дс: р2 = ( {/25 2Sit/i/?3 , U2a2) на основании (с) где (i) ( # 3> р з)= = ^ (Л 2, null x ) — (U3S3R4, £/3 Рз) из (b) ( # 4, Р4 ) = Ж( А2> null) = (/, list truval) из (а) ( 53,Хсг3) = W{ I А2, * ) = (/, Ра) Тогда R3— U3= T( list 0 [ -►truval, 02 -> 03) = [list а^Рг, truval/р3 ] (ii) Ul = T ( p 3, truval) = T {U 3$3, truval) = I (iii) ( 5 b a{)=-W{U^R3A2, 0 ) = (/, пит) (iv) ( S 2, a2) = W{ SiU iR3A2, succ(f(tl x ) ) ) = W(U3A2, succ(f(tl x ) ) ) ( v ) U2 = T ( S2(jh a2) Теперь, повторно применяя (b), получаем S2====U4U3UQ где U4=[num/a4, n«m/0 4] Us = [list cti — Ps/Pi] U3= [cti/ct2* list a (/P6] (Переменные a4, 0 4 , 0 5 , 0e введены при применении случая (b) и не нужны нам больше.) Тогда 02 =

^404 И

а2= и 2= S, = R 1=

пит Т ( а ь пит) — [пит/а/\ £/1= / R2= U2U4U5UsU3 Р 2 — U2o2 — пит Pi = # 2 0 2 -> Р2 = list cti -*■пит Наконец,

t / 0 = y ( f l i P „ px) — T [ l i s t a t -> -P s , lis t a j - > n u m ) = [n u m /P s ]

так что Ж(

A, length ) — ( U0U2U4U5UsU3, lis t a t - > num )

172

Часть II. Глава 7

Для практического использования алгоритм Ж недостаточно эффективен, и Милнер предложил императивный алгоритм £7", который использует глобальные данные для хранения значений переменных типа, и процедуру unify, которая не возвращает результат, а модифицирует эти глобальные данные в качестве побочного эффекта. В действительности легко видеть, что 0~ ■ моделирует Ж, и это дает основу для доказательства того, что д~ и Ж эквивалентны. 7.4. Расширения Ж для практической проверки типов Не говоря об эффективности, отметим, что Ж не вполне подходит нам для практической проверки типов в таких тради­ ционных функциональных языках, как Норе и Miranda, так как некоторые свойства этих языков не включены в синтаксис, с которым работает Ж. В первую очередь это составные типы данных пользователя и сопоставление с образцом, где образцы могут включать кортежи. Включение составных типов данных не является проблемой, так как определенные пользователем типы можно рассматривать как новые примитивные операторы типа (аналогичные, например, — пит и list), а функции-кон­ структоры, работающие с пользовательскими типами, как при­ митивные функции (аналогичные cons, nil и т. д.). В этом слу­ чае определенный пользователем тип каждого конструктора просто добавляется к глобальному набору допущений. Напри­ мер, имея определение данных на языке Норе data shape = = rectangle( num # num ) -|—Ь circle( num ); мы бы добавили следующие допущения: rectangle : пит X пит —>■shape и circle пит -> shape к глобальному набору допущений. Все типы теперь обрабаты­ ваются одинаковым образом, так что если Г 1 и Г2 представляют собой конструкторы, то Т ( Ti (0 1 , в 2 ), Т2( х ьТг)) заканчивается неудачей, если Т \ ф Т 2 (например, если Т\ — rectangle и Т2 = — cons). Как мы уже видели, кортежи могут обрабатываться подоб­ ным образом с помощью семейства примитивов кортежирования tuple-n ( п > 1 ), тип которых в глобальном наборе допущений выглядит следующим образом: tuple-n: cti —»■а2 . . . -> ап —> ( а, X а 2 . . . X ) Однако удобно рассматривать кортежи как специальные объ­ екты из-за их особой роли в определении функций, имеющих

Система вывода типов и проверка типов

173

больше одного аргумента, к которым не применим карринг. Напрашивается поэтому идея расширения алгоритма проверки типов таким образом, чтобы он мог выводить типы кортежей непосредственно, и если мы сделаем такое расширение, то смо­ жем обойтись без рассмотренных выше глобальных допущений. Требуется только одно дополнительное правило для Ж, очевид­ ное в свете наших предыдущих обсуждений. Кортеж в этом правиле мы записываем обычным образом — в круглых скобках: (g) Если е = (еь е2, еп) , пусть { R u Р1) = Г ( Л Ь е,) ( R 2, р2) = Ж ( А ъ е2),

рп) = П А п, еп)

где Л 1 = А и Ai+i = R,Ai ( 1 ^ i ^ п ) . Тогда Т = RnRn~i... ■■■R1 И т = Гф, X Т2 р2Х ••• X ТпРп, где Ti = Rn ... Ri+1 ( 1 < i < п), Тп = /. Однако эта модификация сама по себе ничего не значит, так как в нашем правиле для определения типа ^.-абстракции Xx.f переменная х предполагается простой. Такое предположение вполне подходит, когда имеешь дело с исходными функциями вида g x \Х2 . . . хп — Е , к которым применим карринг, но оно не годится для функций, определенных с помощью кортежей, на­ пример g ( x 1, х2, . . . . х„) = Е. Чтобы выйти из положения, не­ обходимо цвести новый тип выражения, называемый v- абстрак­ цией и записываемый в виде ур.е. Он аналогичен Я-абстракции, за исключением того, что аргумент р может быть произвольным образцом, (мы используем эту идею снова в приложении В). PJoBoe правило алгоритма Ж, определяющее тип v-абстракции, вклЮчаеД в себя правило (d), так как переменная является частным случаем образца, т. е. \х.е — кх.е,' если д: — это пере­ менная. Новое правило подобно правилу (d), за исключением того, что тип образца-аргумента должен быть выведен так же, как и тип тбла абстракции: (d') Если е = \р.Ь, пусть (R, р) = Ж ( А . х 1:&1.

Ря, р)

и (/?', р') = Ж ( Е А . х {: где все р,- ( 1 типа, a xi разец р. Тогда Заметим, что если будет получен для

^ i

Ь)

п) являются новыми переменными — это переменные, входящие в об­ Т = R'R и т = R ' p р'. р является кортежем из k элементов, тип р р с помощью нашего нового правила (g).

174

Часть II. Глава 7

Теперь нам осталось только решить проблему, как вывести тип функции, определенной с помощью т > 1 уравнений, каж­ дое из которых в общем случае имеет вид fPn ■■■ Ptn = ei,

1 < i < m,

где pik ( 1 ^ k ^ n ) является произвольным образцом (в язы­ ке Норе п = 1 ) . Как можем ожидать, при проверке типа i-e уравнение рассматривается как v-абстракция f = vpn. . . . vpla.et Если нам нужно только проверить соответствие -типа f объяв­ ленному пользователем типу т, мы можем просто выполнить отождествление T{ oi, т ) для каждого из т уравнений по от­ дельности, где сг,- — это тип, выведенный для уравнения i ( 1 ^ ^ т ). Если ни одно из отождествлений не закончилось неудачей, мы завершаем проверку типов, считая объявленный тип т правильным. С другой стороны, чтобы вывести наиболее общий тип функ­ ции /, согласующийся с типом каждого из т уравнений, необ­ ходимо отождествить типы, выведенные для этих т уравнений. Это дает новое правило (h) для алгоритма Ж. (h) Если / определена с помощью набора из m уравнений {fpn ... Pin(i) = ег| 1 < i ^ m ), тогда W( A, f ) = (Qm ... ... QiSi ... S m, QmOm) , где Qk = T ( Qk~\Ok-\, Ok) (2 ^ k ^ ^ m ), Qi = I и'для 1 ^ i < m, ( Si, cn) = Ж { A, fix f.vpn---• • • vpin(i)-ei). Теперь мы имеем всю необходимую информацию, чтобы реали­ зовать проверку типов для практических полиформных функ­ циональных языков, таких, как Норе, и отсылаем всех, интере­ сующихся дополнительными сведениями, к опубликованным статьям по данному вопросу, например к более эффективному алгоритму ZT Милнера и к программе контроля типов Карделли [2 0 ]. В заключение рассмотрим вопрос определения типов для множества взаимно рекурсивных функций. Предположим, что функции f u g определены как взаимно рекурсивные. Тогда, используя оператор fix, можем выразить / только через g (и g только через /), возможно используя в каждом случае сопостав­ ление с образцом. Таким образом можно найти тип f исходя из предположения о типе g и, с другой стороны, можно опре­ делить тип g исходя из предположения о типе f. Все, что нам теперь осталось сделать, — это отождествить выведенный и предполагаемый типы f. Эти рассуждения приводят-нас к сле­ дующему правилу:

Система вывода типов и проверха типов

175

(i) Если {fi = ei\ 1 ==Сi ^ n } является набором взаимно рекур­ сивных уравнений для идентификаторов функций пусть Ai — A-fi •' Pi.......... ft - 1 Pf-i-fi+i • Pt+i........... fn • Pn и ( Я ь pi ) = Ж(Rl_l . . . R,Att et ) (1 Тогда для любого k, 1 ^ k ^ п, Ж( A, f k ) = ( USUт ), где Si — R n .- .R i t = i / 5 i P * и U = y(Sk+iPk,S$k). 7.5. Ограничения Ж В заключение этой главы вернемся к вопросу о цикличе­ ски определенных типах и рассмотрим проблемы вывода таких типов с помощью Ж. Предположим, мы хотим вывести тип выражения е = = Kx.consxx. Применяя Ж к е с набором допущений А, вклю­ чающим cons : а list (а) -*■ list (а), получим Ж(А, e) = (R, ЯР-^р) где {R, р ) = Ж( А . х : р, cons х х )

= (£/.*!, *ЛР>) где U \ =

( R lt

У ( S i p i , С[ -н►Pi ) (после некоторого упрощения)

W°{A x : $ , c o n s х) = ([а/Р, list( а ) —>-list( а )/р2 ], tist( а ) -* list( а ))

Pi) —

(после

некоторого упрощения) И (Si,

ol)= Ж ( R l(A.x^.n х)

= Ж^ ( RlA) . x : a , х) = (Л «) Таким образом Ui = У (list (a)-*-list ( а) , ос—>- Э:) и отож­ дествление закончится неудачей, так как «проверка вхождения» обнаружит, что переменная а входит в другой компонент не­ согласованной пары list ( а ) . Если мы допускаем бесконечные типы, можем видеть, что тип выражения consxx имеет вид list( list( . . . ( list( а ) ) . . . ) ) = list°°( а ) и что типом Kx.consx х является поэтому а )-*- /is/°°( а ). Не было бы серьезной проблемы, если бы наша проверка типов заканчивалась неудачей только в случае бесконечных типов, но, к сожалению, проверка вхождения накладывает более серьез­ ные ограничения. В частности, отождествление всегда завер­ шится неудачно, если мы попытаемся вывести тип выражения,

176

Часть II. Глава 7

содержащего самоприменение, например такого, как У-комбинатор, имеющий тип ( а - > а ) - > а . Чтобы убедиться в этом, рассмотрим выражение хх, кото­ рое имеет тип т, получаемый из Щ х : Р, х x ) = (USR, £/р') на основании правила (Ь) (R, р ) = (S, а ) = ( / , Р ) И U = r(Sp , а — Р') = Г(Р, Р->Р') Таким образом, отождествление U даст ошибку, так как переменная р входит в выражение типа p -v p '. Некоторые схе­ мы проверки типа несколько более сложны, чем рассмотренная нами, и не выполняют немедленно проверку вхождения. Н а­ пример, в случае У-комбинатора, определенного в виде У = == Xf. ( Xx.f ( х х )) ( Xx.f ( х х )), может быть выполнена следую­ щая цепочка выводов (опускаем несколько промежуточных шагов):

где

х : а = а —ух хх : т f : т —►х' f ( x x ) : х' Хх. f ( х х ): а —►г' Самоприменение ( Xx. f ( xx) ) дает вывод (Xx.f ( х х )): а = а и, таким образом, мы имеем равенства

т',

а = о —>х — о -+%' из которых можем заключить, что т = х' и, следовательно, / : т-»-г. Теперь уже легко сделать вывод, что У:(т-»-т)-»-т. Вывод о том, что х = х', был сделан не с помощью алгоритма. Он является примером дополнительных возможностей, кото­ рыми должна обладать схема проверки типов, способная рабо­ тать с простейшими циклическими типами. Резюме • Проверка типов позволяет выявить много программных оши­ бок на этапе компиляции и избежать проверки типов на этапе выполнения, что повышает эффективность программ. • Тип выводится для каждого подвыражения в программе и проверяется на непротиворечивость. Типы, объявленные поль­ зователем, также проверяются сравнением с выведенными типами.

Система вывода типов и проверка типов

177

• Let-связанные переменные типа являются родовыми и могут быть заменены разными типами в одном и том же выражении; ^.-связанные переменные не являются родовыми и таким свой­ ством не обладают. • Логическая система вывода может вывести тип выражения формально, но не автоматически. • Алгоритм Ж Милнера выводит поверхностные типы, эффек­ тивно автоматизируя процедуру доказательства для простой си­ стемы вывода типов. • Ж может быть расширен, чтобы иметь возможность работать с сопоставлением образцов, но проверка вхождения в алго­ ритме отождествления может приводить к неудаче при проверке типов некоторых правильно типизированных выражений, таких, как Y. Упражнения 7.1. Найдите неформальным способом наиболее общие типы сле­ дующих функций языка Норе: a ) --------f( а, Ь, с ) < = if a(b) then [с] else [b :: с ]; b) --------g(x, у) < = lambda z = > if z(x) then у else z ; c ) ------- h(a, b, c ) < = [lambda x = > ( b ( x , 2 ), c(b(a, l ) ) x ) ] ; 7.2. Имея допущение pair : a-*- [ 5 — > - ( a X P ) , мощью Ж типы следующих функций: funpair = kf.Kg.Ka.Kb.(f a, g b) tagpair = Xa. f unpair ( pair a, pair a)

выведите с по­

7.3. а. Используйте расширенную версию Ж, чтобы вывести тип функции apply, определенной на языке Норе следующим об­ разом --------applu ( f, х) < = f х ; б. Теперь допустим, что кортежи не выражаются с помощью синтаксиса исходного языка, а представляются с помощью яв­ ного применения функций кортежирования. Определите в этом случае функцию apply. в. Выведите тип функции apply, которую вы определили в пункте б, предполагая, что типы функций кортежирования включены в глобальный набор допущений. (Вам теперь не по­ надобится правило (g).) 7.4. Определите функцию языка Норе, которая применяет карринг к функции арности 3, и выведите ее тип, используя расши­ ренную версию Ж. 12



1473

178

Часть 11. Глава 7

7.5. В новом правиле (d'), введенном для обработки сопостав­ ления с образцом, мы должны найти в аргументе р новые пере­ менные х, чтобы вывести тип р. Покажите, как можно обойти этот поиск, определив v-правило рекурсивно с A-правилом в ка­ честве базового случая; при этом нужные переменные будут включены в набор допущений. Предположите, что ко всем функциям-конструкторам и всем функциям кортежироЬания применим карринг, а их типы включены в набор допущений. (Подсказка: для функции f = v ( c p \ ' ... рп ).е, п > 0 , рассмот­ рите Ж {A, vpi. ... vpn.e) и определите базовые случаи, когда р — это переменная, а арность с равна 0 ). 7.6. Покажите, как Ж делает вывод о том, что следующие функции не имеют типов: а) sum t п = М л = 0 then t else sum(/-{-« ) (Это классический пример функции, для которой нельзя выве­ сти тип и которая, к счастью, не может быть выражена сред­ ствами языка Норе. Ее тип — это объединение типов гшт-+ пит -*■пит, пит-+пит-+пит-+пит, ... и все это можно записать в виде: s um : пит —>пит -* ( FIX т . ( пит -j—)- пит -> х )) где FIX — это оператор наименьшей фиксированной точки для выражений типа, а -)—)- обозначает объединение типов.) б) taut 0 f = f taut( п -f- 1 ) / = taut n( f true) и taut n( f false) (Эта функция потенциально «полезна»!) Имея предикат (к ко­ торому применим карринг), следующий за ее арностью в каче­ стве аргумента, она возвращает true, если предикат является тавтологией, и false в противном случае. Ее тип имеет вид пит-*- bool-+ FIX r.(bool-\—f-bool-^x.) 7.7. Опишите в общих чертах модификацию Ж, необходимую, для поддержки совмещения идентификаторов.

Глава 8 ПРОМЕЖУТОЧНЫЕ ФОРМЫ

В этой главе мы предложим промежуточный код для функ­ циональных языков, основанный на нотации Я-исчисления, описанного в гл. 6 . Мы несколько расширим эту нотацию, что­ бы иметь возможность именовать выражения; это позволит создать простой механизм для описания рекурсивных функций, что в свою очередь упростит трансляцию исходной программы в промежуточный код. Результирующий код можно рассматри­ вать как модифицированную версию Х-нотации, вследствие чего она будет использоваться во многих методах реализации, опи­ санных в следующих главах. После того как промежуточный код будет описан, мы пока­ жем, как транслировать в этот код язык высокого уровня. Мы делаем предположение, что перед трансляцией программа прошла фазу синтаксического разбора и проверки типов, т. е. является правильно типизированной, и представлена в виде абстрактного синтаксического дерева. Таким образом, фаза трансляции заключается в отображении абстрактного синтакси­ ческого дерева исходной программы в абстрактное синтакси­ ческое дерево эквивалентной программы на промежуточном коде, хотя в рамках данного обсуждения будем считать, что трансляция происходит из исходного текста в исходный текст. Это дает* сжатый и абстрактный способ выразить правила трансляции. Конкретный промежуточный код можно получить из результирующего абстрактного синтаксического дерева, хотя вполне вероятно, что именно последнее в дальнейшем будет обрабатываться в соответствии с методами, описанными в сле­ дующих главах. В данной главе мы сосредоточимся на правилах трансляции для языка Норе, так как в этой книге он является нашим основ­ ным исходным языком. Однако, и это очевидно, трансляция других исходных языков может быть описана подобным об­ разом. 1

2*

180

Часть И. Глава 8

8.1. Промежуточный код для функциональных языков Промежуточный код, который мы здесь описываем, — это Я-нотация, рассмотренная в гл. 6 , но расширенная дополни­ тельными средствами наименования выражений. Синтаксис выражения ехр промежуточного кода можно записывать в виде БНФ следующим образом: (exp)::= (id) | Я (id). (exp) | (exp) (exp) | ((exp)) | con | let (def) in (exp) | letrec (defs) in (exp) (defs) ::= (def) | (def), (defs) (def) ::= (id) = (exp) (id) ::= идентификатор (con) константа Из двух расширений Я-нотации letrec является более важ­ ным, так как let-выражения можно эквивалентно определить с помощью вспомогательных функций: let x = Et in Е2 = (Ях .Е2 ) Е 1 Мы включаем в промежуточный код let-выражения главным образом ради удобства, хотя это дает также некоторые преиму­ щества в смысле эффективности, что станет ясным, когда мы рассмотрим в гл. 15 компиляторы функциональных языков. Набор констант, как и раньше, произволен. Реализация функ­ ционального языка, основанная на этом типе промежуточного кода, будет использовать, по всей вероятности, большое число примитивных функций и несколько базовых типов. Все прими­ тивные функции, которыми мы будем пользоваться в этой главе, приведены в табл. 6.1. Кроме этого нам понадобится семейство примитивов выбора: CASE-1, CASE-2 и т. д., определяемое следующим образом: CASE-n SEE qE ^ z . . . En_i = Es, если 0 = E, если S
Е ! = L A M R U L E (H [P 1, Щ Е Ц ) E Q U IV Эквивалентность в образцах, например х& у :: 1 Н I v & Р ] = EQUIV ( VAR( v ), Н [ Р ] ) Рис. 8.2. Правила синтаксического разбора языка Норе.

Промежуточные формы

генерируемое каждым типом конкретного Эти правила записываются в виде

183

Норе-выражения.

Н [ hope-выражение ] = Абстрактное синтаксическое дерево Мы используем биквадратные скобки [ и ], чтобы показать, что аргумент Н является синтаксическим объектом. 8.3. Трансляция языка Норе в промежуточный код За исключением определений функций, ^-выражений и ква­ лифицированных выражений (let- и where-выражений), где ис­ пользуется сопоставление с образцом, все остальные выражения языка Норе транслируются непосредственно в промежуточный код. Трансляцию можно рассматривать как процесс выполнения транслирующей функции Т, отображающей исходные выраже­ ния в выражения промежуточного кода. Мы будем выражать правила функции Т на уровне синтаксических объектов, хотя на практике в большинстве случаев в промежуточный код транслируется не сам текст программы, а полученные из него абстрактные синтаксические деревья. Мы будем записывать каждое правило в следующем виде: Т [ исходное выражение ] = Выражение промежуточного кода (Биквадратные скобки (I и ])> как и ранее, показывают, что аргумент Т является синтаксическим объектом). Правила транс­ ляции для подмножества выражений, не требующих сопостав­ ления с образцом, имеют следующий вид. 1. Литералы базовых типов (например, целые числа, символы, действительные числа и т. д.). Они не изменяются в процессе трансляции. T[n]] = n, п — это литерал 2. Идентификаторы. Если идентификатор является одной из встроенных функций языка Норе, нужно выполнить отображе­ ние имен, чтобы гарантировать правильный выбор функции промежуточного кода (одна и та же встроенная функция может иметь разные имена в исходном тексте программы и в проме­ жуточном коде). В противном случае функция Т не изменяет идентификатор. Мы будем обозначать через Bf эквивалент для промежуточного кода встроенной функции f языка Норе: T [ f ] = Bf (f — это идентификатор встроенной функции) Т [ i 1 = i ( i — это любой другой идентификатор)

184

Часть II. Глава 8

Вспомним, что функции языка Норе не обладают свойством карринга и имеют поэтому только один аргумент (который может быть кортежем). Это означает, что примитивные функ­ ции, подобные функции «+», аргумент которых является кор­ тежем чисел, должны транслироваться в функции, явным обра­ зом разделяющие этот аргумент на части. Например, В+ = = plus, где plus = A,t. + ( INDEX 11) ( INDEX 2 1). Определения таких функций, как plus, могут быть явным образом включены в транслируемую программу в качестве «библиотечных» опре­ делений. На практике, однако, разумно включать их в набор констант. Другое решение состоит в том, чтобы выполнить не­ которую оптимизацию Т, позволяющую более пристально рас­ сматривать структуру применения функций в исходном тексте и использовать «карринговые» версии примитивных функций всякий раз, когда это возможно. Например: Т [ + ( Е,Е2) ] = plus т с е , ш е 2] Все сказанное выше, конечно, не относится к языкам, обладаю­ щим свойством карринга, для которых мы имеем просто В+ = +• 3. Условные выражения. Они транслируются в вызов встроенной функции COND. Предикат и выражения, идущие после then и else, в свою очередь должны сами транслироваться с помощью функции Т: T [ i f Д , then E2 else E3] = COND T [ E 1 ] T [ E 2 ] T [ E 3]1 4. Кортежи. Они транслируются встроенной функции TUPLE-n:

непосредственно

в

вызов

Т [ ( Е Ь Е2, . . . , En)] = TUPLE-n Т [ Е, ] Т [ Е2 ] . . . Т [ Е„ ] 5. Применения функций. Поскольку как функция, так и ее ар­ гумент являются выражениями, мы просто применяем Т к ним обоим: Т [ Е]Е2] = T f E I ] T [ E 2] 8.3.1. Представление составных данных Перед тем как перейти к рассмотрению трансляции сопо­ ставления с образцом, рассмотрим представление составных данных. Метод, который мы собираемся использовать, состоит в применении семейства функций кортежирования TUPLE-0, TUPLE-1 и т. д., которые были введены в ^.-исчисление в гл. 6 . Для того чтобы понять идею, рассмотрим пример определения на языке Норе типа данных data для представления двоичного

Промежуточные формы

185

дерева: data tree ===== empty —(- leaf( num) -j—(- node(tree # num # tree); Это определение вводит три новых конструктора (empty — пу­ стое дерево, leaf — лист и node — узел), которые можно исполь­ зовать в процессе сопоставления с образцом для определения типа дерева. При сопоставлении с образцом необходимо иметь возможность различать применения различных конструкторов, и, чтобы добиться этого, каждому из этих конструкторов надо давать уникальную метку. (Эти метки должны быть уникаль­ ными только в рамках конкретного типа данных, так как строгая типизация выражений исходного языка гарантирует, что когда мы, например, ожидаем дерево, мы всегда его най­ дем.) Таким образом, мы будем связывать целые 0, 1 и 2 с конструкторами empty, leaf и node соответственно; причина такого соглашения о нумерации станет ясна позднее. В общем случае, если в определении типа данных имеется п конструк­ торов, мы будем использовать целые 0 , 1 , . . . , п — 1 . Используя такие. метки, можем теперь представить приме­ нение конструктора арности п в виде кортежа из п + 1 элемен­ тов, первый элемент которого является меткой конструктора, а остальные п элементов — это аргументы конструктора, на­ пример: empty — TUPLE-1 О leaf(n) ^T U P L E -2 1 T i n ] node(x, у, z ) -^-TUPLE-4 2 T [ x l T [ y ] T [ z ] | В общем случае применение Т к конструктору с имеет вид Т[с В дальнейшем эти уравнения называются правилами. — Прим, перев.

Промежуточные формы

187

щий форму аргумента, называется кодом сопоставления. После определения применимого правила мы должны связать пере­ менные подходящего образца с соответствующими компонен­ тами аргумента. В первом правиле определения функции sum образец не имеет переменных, поэтому мы транслируем выра­ жение тела непосредственно. Для второго правила мы должны перед трансляцией его правой части x + s u m( l ) сгенерировать связывающий код для переменных х и 1. Код тела функции в этом случае является комбинацией связывающего кода и для соответствующего выражения правой части. Код сопоставления может последовательно соотнести каж­ дый образец с аргументом, чтобы определить, соответствуют ли они друг другу, но на практике более эффективно слить все эти тесты в единое дерево сопоставления. Это дерево опреде­ ляет часть промежуточного кода, который при выполнении воз­ вращает целое число, представляющее собой номер правила, образец которого соответствует аргументу функции. Если ни один образец не сопоставим с аргументом, возвращается — 1 . Набор правил, определяющий упомянутую выше функцию f, транслируется таким образом в единственное CASE-выражение, выбирающее связывающий код, который соответствует номеру правила, возвращаемому кодом дерева сопоставления: Aa.CASE-n код для дерева сопоставления ошибка ((связывающий код для правила 0^ (код для Е0)) ((связывающий код для правила 1 ) (код для Е[)) ((связывающий код для правила п — 1 ) (код для Еп- 1 )) Заметим, что если код сопоставления возвращает —1, то ни один из образцов не соответствует аргументу. В этом случае программа должна завершиться соответствующим сообщением об ошибке. Все эти действия выполняются при выборе второго аргумента CASE-n. 8.4.1. Генерация дерева сопоставления Метод генерации дерева сопоставления из набора уравне­ ний, определяющих функцию, который мы здесь описываем, является вариантом метода, описанного Хантом [49]. Детали альтернативных подходов можно найти у Аугустсона [6 ] и Уодлера [85]. Основная идея состоит в следующем: сначала

188

Часть II. Глава 8

мы генерируем отдельное дерево сопоставления для каждого уравнения в определении функции, затем сливаем все деревья в одно, которое транслируется в промежуточный код. Резуль­ тат выполнения этого кода равняется — 1 , если ни один образец не соответствует аргументу, и к (к ^ 0 ), если аргумент соответ­ ствует образцу к-го уравнения. (Как и ранее, предполагается, что уравнения пронумерованы от 0 до п — 1 .) Каждое уравнение в определении функции f на языке Норе имеет вид --------fPi < = Е ,; где Р, — это образец, который может быть ( 1 ) переменной, например х; ( 2 ) подчеркиванием (-), которое с точки зрения генерации де­ рева сопоставления эквивалентно переменной; (3) термом конструктора вида СР, где Р — это выражение об­ разца (вспомним, что такие конструкторы представляются в виде кортежей, у которых первым компонентом является Nc); Р мо­ жет быть пустым, в этом случае С является константой данных; (4) кортежем образцов вида (Рь Р2, . . . , Pm), где Р, (1 ^ ^ i ^ ш) — это выражение образца. (На этапе сопоставления эквивалентность вида v& P — это то же самое, что и Р.) На практикечмы также можем записывать литералы (например, 3) в образцах, но здесь мы не будем рассматривать эту возмож­ ность, как не влияющую на понимание сути алгоритма. Мы увидим, как обрабатываются выражения образцов, содержащие литералы, в разд. 8 .6 . Всякий раз, когда в образце встречается терм конструктора, нужно проверять наличие этого конструктора в соответствую­ щей позиции аргумента. Этот тест представляется в дереве сопоставления в виде внутренней вершины, имеющей форму (позиция, список деревьев сопоставления) «Позиция» — это спецификация пути, идентифицирующая про­ веряемый компонент аргумента. Она записывается в виде спи­ ска целых индексов: пустой список [ ] означает все выражение аргумента функции; [i] означает i-й элемент кортежа аргумен­ тов; [i, j] означает j -й элемент i-ro элемента; [i, j,k] означает k-й элемент j-ro элемента i-ro элемента и т. д. Например, для уравнения --------g( х, С,( у,, у2), С2( С3( г ), п )) < — где Ci и С2 являются конструкторами, [ ] означает весь кор­ теж аргументов; Ш означает первый элемент кортежа аргу­ ментов, т. е. х; [2 ] означает второй элемент кортежа аргумен­

Промежуточные формы

189

тов, т. е. Ci(yi,y2); [2 , 1 ] означает первый элемент второго элемента кортежа аргументов, т. е. конструктор Сь и т. д. Спе­ цифицированный путь всегда ведет к коду конструктора (т. е. его последний компонент всегда равен 1 ), который используется для выбора одного из поддеревьев в «списке деревьев сопо­ ставления». Число поддеревьев в этом списке в точности совпа­ дает с числом конструкторов соответствующего типа данных. Все, кроме одного, из этих поддеревьев соответствуют неудач­ ному сопоставлению, и мы будем представлять их в виде пустых деревьев сопоставления. Одно поддерево, соответствующее слу­ чаю совпадения кодов конструкторов образца и аргумента, строится по точно такому же принципу, как и все дерево со­ поставления: путем перебора всех конструкторов образца по методу «в глубину слева направо». Чтобы сделать это объяс­ нение совершенно понятным, на рис. 8.3 показано дерево сопо­ ставления для следующего (i-ro) уравнения в определении функции f: --------f( empty, node(leaf(x), k, node( empty, y, t)), n i l ) < = E , ; Тип этой функции имеет вид: tree # tr e e # list( . . . . , где tree — это тип данных, определенный нами выше. На рисунке предполагается, что конструкторы списков nil и :: имеют коды О и 1 соответственно, причем :: считается префиксным кон­ структором (вспомним также, что пустые конструкторы, та­ кие, как empty и nil, должны рассматриваться как кортежи из одного элемента). В этом примере порядок проверки конструкторов образца определен в виде «в глубине слева направо». Символ X на ри­ сунке обозначает пустое дерево сопоставления, соответствующее несовпадению образца и аргумента, а лист {i} обозначает успешное совпадение аргумента и образца i-ro уравнения. Бу­ дем обозначать через G[ Р 1 дерево сопоставления, сгенерированное из образца Р, а через Рт образец уравнения с правой частью Ет . Тогда дерево на рис. 8.3 обозначается G [ P i ] . После того как будет сгенерировано дерево сопоставления для каждого уравнения в определении f, необходимо сделать следующий шаг — слить эти деревья в одно. Для иллюстрации этого процесса введем в наше определение f еще одно урав­ нение: ——f(leaf(x), leaf(y), х :: 1 ) < = E j;

190

Часть II. Глава 8

Тестируемый конструктор empty

( [1 ,1 ], [ ] )

node

( [ 2 . 1 ] , [ ])

leaf

X

node empty

X X

X ( [ 2, 2,1 ], [ ]) X

([2 ,4 ,1 ], [ ]) X X

X

([2 ,4 ,2 ,1 ], [ ]}

leaf

Рис. 8.3. Дерево сопоставления для уравнения i.

Дерево сопоставления G [ P j ] показано на рис. получившееся в результате слияния G [ P i ] и

8.4, а , G [P j],

а дерево, показано

([ 1 . 1 ] [ 1 > ([2 . 1 ],[ ]> ([ 2 ,1 ], [ )) X

X {[2,2 1],[ ])

X

Х( [ 3. 1] , [

]

/\ х'^ТГг,3.1 ].[ ]) X

X hi

X X {[2 ,3,2 , 1 ], [ ]) {[3.1],[

])

X

X

ж"?

h I X

б Рис. 8.4. Слияние деревьев: а — дерево сопоставления для уравнения j; б — дерево, полученное в результате слияния.

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

Промежуточные формы

191

рис. 8.4, б, можно формально обозначить через M (G [P ,],

G [ P j|)

Операция слияния объединяет те вершины, которые проверяют одинаковые компоненты аргумента. Чтобы определить опера­ цию М, необходимо упорядочить «позиции» каждой вершины деревьев сопоставления. Отношение порядка на множестве по­ зиций соответствует принятому нами порядку тестирования конструкторов образца по методу «в глубину слева направо»: [ ] < Ш < [ 1 , П < [ 1 , 1, 1 ] < ... < [ 1 , 2] < [ 1 , 2 , 1] < .. .[2] < -----

Правила слияния двух деревьев Ti и Т2 (обозначаемого М( Т, , Т2)) можно записать следующим образом: (1) Если одно из деревьев Т[ или Т2 является пустым, опера­ ция М возвращает другое дерево. Это соответствует расшире­ нию дерева сопоставления по сравнению с тем, каким оно было до слияния: М(Х, Т) = Т М(Т, Х) = Т (2) Если Ti и Т2 являются вершинами (невисячими), то про­ цесс их слияния зависит от сравнения позиций (pi и р2), ука­ занных в этих вершинах. Если pi — р2, то обе вершины прове­ ряют один и тот же компонент в выражении аргумента, и по­ этому каждое поддерево Т) сливается с каждым поддеревом Т2. Если pi < р2, то все вершиьы Т2 проверяют такой компо­ нент аргумента, который текстуально расположен справа от компонента, указанного pt. Следовательно, каждое поддерево Т, сливается с Т2. Если pi > р2, то каждое поддерево Ti сли­ вается с Т2: M(T,&(P„ [t]0, р 2.

Символы = , < и > являются, конечно, булевыми функ­ циями на множестве позиций, а не на множестве целых чисел. Заметим, что у нас нет правила для слияния листьев, по­ скольку образцы не перекрываются, как мы предположили в начале этого раздела. Поэтому листья сливаются по правилу для пустых деревьев, приведенному выше (случай (1)). Подроб­ ное рассмотрение этого вопроса читатель найдет в разд. 8.5.

192

Часть II. Глава 8

Полное дерево сопоставления для множества образцов Pj, Р2, . -., Рп-ь Рп определяется следующим образом: М( G[P,], М( G[P2], . . . , М( G[Pn-i], G[P„]) . . . ) ) Мы завершаем обсуждение описанием правил преобразова­ ния результирующего дерева сопоставления в промежуточный код. Правило трансляции назовем Е. Вспомним, что в резуль­ тате выполнения промежуточного кода выдается —1, если со­ поставление закончилось неудачей, и п, если аргумент соответ­ ствует образцу n-го уравнения. Нам понадобится вспомогательная функция Р, транслирую­ щая «позицию» в промежуточный код, выбирающий компонент аргумента, определяемый позицией. Например: Р( а, [2, 3, 3])== INDEX 3 (INDEX 3 (INDEX 2 а ) ) где а — это имя аргумента. Правила для Е имеют следующий вид (а снова обозначает имя аргумента): Е( а, Х) = —1 Е(а, {п}) = п Е(а, ( n, C L O S U R E ) e, N e w E n v ) ) defs );

---------Apply! C L O S U R E ! L A M (v, В ), e n v ) , A ) < = E v a ! ( B , e n v : + : ( v , A ) ) ; ---------Apply! O P ( p, РАС, arg s ), A ) < = ( i f РАС = 1 then ( F un01( p ) ) ( a r g l i s t ) else O P( p, РА С — 1, a r g l i s t ) ) where a r g l i s t = = arg s ( ) [ A ] ; ---------ArityOf! " + " ) < = 2 ; ---------A r i t y O f ! " - " ) < = 2 ; ---------FunOf( " + " ) < = lam bda[ INT( a ), INT( b )] = > INT! a + b ); ---------F unO f( ) < = lambda[ INT( a ), INT( b )] = > INT( a - b ) ; Рис 9 2 Энергичный интерпретатор.

образом расширен. Таким образом, результат частичного при­ менения примитивной функции сам является примитивной функцией: --------АррГу(ОР(р, РАС, args), А) < = ( i f РАС-1 then (F unO f(p))( arglist) else OP(p, PAC-1, arglist)) where arglist ===== args ( ) [A] FunOf — это функция высшего порядка, которая возвращает Hope-функцию, требуемую для реализации данного примитива

Методы интерпретации

217

(еще одно полезное применение функций высших порядков). Ее объявление типа выглядит следующим образом: dec FunO f: id -> ( list( exp) ->exp); Возвращаемая функция должна быть написана так, чтобы при­ нимать аргументы примитива в виде списка выражений, по­ скольку в конструкторе ОР аргументы примитивной функции накапливаются в виде списка (args). Здесь мы можем призвать в помощь такое мощное средство языка Норе, как сопоставле­ ние с образцом: --------F u n O f("+ ") < = Iambda[INT(а ), INT(b)] = > INT(а + b ); --------F u n O f("-")< = la m b d a [IN T (a), INT(b)] = > I N T ( a - b ) ; Заметим, что аргументы этих функций вычислены заранее (т. е. приведены к виду IN T (x )), так как рассматриваемый интер­ претатор реализует передачу параметров по значению. Это объ­ ясняет, почему в каждом из записанных выше лямбда-выраже­ ний необходим только один образец. Полный листинг интерпретатора на языке Норе приведен на рис. 9.2. Простота результирующей программы еще раз под­ тверждает выразительную мощность функциональной нотации. 9.2.3. Обработка условных выражений Построенный нами интерпретатор реализует вызов по зна­ чению для каждой функции. Это означает, что если мы реали­ зуем условные выражения в виде COND-функций, то будем вы­ числять выражения обеих ветвей условия, а также выражение предиката всякий раз при применении таких функций. Это почти наверняка приведет к тому, что программа не будет работать. Традиционный путь решения этой проблемы заключается в осо­ бом рассмотрении условных выражений, что требует включения дополнительного конструктора в определение ехр: data exp = = INT( num ) + + - .. . . . + + CLOSURE(exp# environment)/ + + C O N D (exp#exp#exp); Это означает, что для данного интерпретатора выражение про­ межуточного кода cond E iE2E3 необходимо транслировать в следующее абстрактное синтакси­ ческое дерево: c o n d ( e ;, Е', Е'3)

218

Часть U. Глава 9

тогда как по общим правилам оно оттранслировалось бы в вы­ ражение АРР( ДРР( АРР( PRIM( "cond" ), Е[), Е '), Е' ) где E'k —это абстрактное представление Е*. Если мы допустим здесь, что true и false представляются целыми числами 1 и О соответственно (на практике логические величины относятся к отдельному базовому типу), то дополнительное правило для Eval будет выглядеть следующим образом: --------Eval(COND(El, Е2, ЕЗ), e n v ) < = if Eval(El, env) = IN T (l) then EvaI(E2, env) else Eval(E3, env); Заметим, что этот дополнительный конструктор требуется только в энергичной реализации; в ленивой реализации (один из вариантов которой описан ниже), условные выражения мо­ гут транслироваться в вызовы примитивной функции cond. 9.3. Ленивый интерпретатор

Теперь мы модифицируем энергичный интерпретатор, опи­ санный в разд. 9.2, чтобы обеспечить вычисление нормального порядка. Сначала мы реализуем вызов по имени, а в разд. 9.3.1 увидим различные пути реализации разделения и, следователь­ но, вызова по необходимости. Первое и наиболее очевидное изменение, которое необходимо сделать, касается правила для функции Eval, когда ее аргумен­ том является применение функции. Мы по-прежнему требуем, чтобы функция была либо замыканием, либо примитивом, и нам, следовательно, по-прежнему необходимо вычислять выра­ жение самой функции перед вызовом Apply. Однако теперь мы хотим задержать вычисление аргумента до тех пор, пока нам не потребуется его значение. Мы можем просто передать выра­ жение аргумента без изменений в функцию Apply, но это снова поставит перед нами проблему, связанную со ссылками на пе­ ременные в выражении аргумента. Следовательно, мы должны использовать структуру, подобную замыканию, чтобы запоми­ нать как само выражение аргумента, так и контекст, содержа­ щий корректные связи переменных этого выражения. Назовем эту структуру задержкой, чтобы отразить тот факт, что она представляет «отложенное» вычисление выражения, хотя ее час­ то называют также рецептом, отражая тот факт, что она пред­ ставляет рецепт для вычисления значения. Поэтому мы модифи-

Методы интерпретации

219

цируем определение данных ехр следующим образом, data exp = = INT(num ) + + . . . . . . —(- CLOSURE(exp#environment) Ч—Ь SUSP(exp # environment); Правило для Eval, когда ее аргумент является применением функции, теперь принимает вид --------Eval(A PP(El, Е2), e n v )< = Apply( Eval( E l, env), SUSP(E2, en v )); Теперь необходимо включить также правило для Eval в слу­ чае, когда аргументом является задержка. Оно требует, чтобы выражение внутри задержки было вычислено с помощью ре­ курсивного вызова Eval: --------Eval(SUSP(E, env), _ ) < = E v a l ( E , env); Вследствие эквивалентности выражений letx = E iin E 2 и (Xx.E2)E i мы должны также модифицировать правило для Eval, когда ее аргументом является LET-выражение, по анало­ гии с тем, как это сделано в случае АРР-выражения: --------Eval(LET(v, El, Е2 ), e n v )< = Eval(E2, env: + :(v, SUSP(E1, e n v ))); Как видим, теперь контекст может содержать задержки, по­ этому не гарантируется, что при поиске в контексте связи иден­ тификатора мы получим выражение в слабой заголовочной нормальной форме. Следовательно, к каждому найденному вы­ ражению мы должны теперь применять Eval: --------Eval(VAR(v), env) < = Eval(env||v, env); Чтобы завершить модификацию Eval, следует расширить пра­ вило вычисления для RLET. Теперь, когда мы имеем задержки, можно допустить любой набор взаимно рекурсивных определе­ ний (а не только рекурсивных функций) с помощью построе­ ния задержки для каждого рассматриваемого определения вме­ сто предположения о том, что определение является функцией, и построения замыкания для каждого такого определения, как ранее. Поэтому модификация Eval в случае, когда ее аргумен­ том является RLET-выражение, имеет простой вид: -------- Eval( RLET( defs, Е), env) < = Eval( Е, NewEnv) where NewEnv = = env : + + : map( lambda (n, e ) = > (n, SUSP(e, NewEnv)), defs);

220

Часть II. Глава 9

Нам осталось изменить только правила для примитивных функ­ ций. Поскольку аргументы примитивной функции могут быть задержками, их нужно вычислять в самом начале: --------FunOf( " + " ) < = lambda [ х, у ] = > INT( а + b ) ; where ( INT( а ), Ш Т ( Ь ) ) = = ( Eval( х, empty), Eval(y, em pty)) ; --------FunOR"—" ) < = lambda [ x, у ] = > INT( a — b ) ; where (IN T (a), I N T ( b ) ) = = ( Eval( x, empty), Eval( y, empty)); Заметим, что в каждом случае Eval вызывается с пустым кон­ текстом, поскольку контекст для вычисления каждого аргумента содержится в соответствующей задержке. Полный листинг ле­ нивого интерпретатора приведен на рис. 9.3. В качестве отступления заметим, что возможно представить задержки с помощью замыканий. Имея выражение аргумента Е, мы можем использовать (3-абстракцию, чтобы преобразовать Е в выражение (Ях.Е )у для любого у при условии, что ни х, ни у не являются свобод­ ными переменными в Е. Более того, если мы введем понятия пустого параметра, обозначаемого , и пустого выражения, обозначаемого ?, тогда можно записать выражение Е в виде U ® .E ) ? не заботясь больше об именах переменных. Сделав это, можно сформировать замыкание результирующей функции (т. е. Х.Е) и, таким образом, промоделировать задержку. Итак, мы пришли к следующей эквивалентности: SU SP(Е, env)^C L O SU R E (L A M (® , Е ), env) История, однако, еще не закончена, поскольку мы должны яв­ ным образом «форсировать» вычисление Е всякий раз, когда нам требуется значение Е. Чтобы сделать это, необходимо при­ менить замыкание для Е к пустому аргументу. Это должно быть сделано только тогда, когда значение выражения действительно необходимо, т. е. в процессе интерпретации строгих примитивов, таких, как + и —. Поэтому, например, в случае примитива + , мы имеем --------FunOf(' + ' ) < = lambda [ х, у ] = > INT( а + Ь ) where (IN T (a), IN T(b)) = (Eval(APP(x, ?), empty), Eval(APP(y, ?), em pty));

Методы интерпретации type

id

221

===== l i s t ( c h a r ) ;

data e xp = = 1 N T ( n u m ) -{— |- V A R ( i d ) + + A P P ( e xp ф e x p ) -j- + L A M ( i d ■ L E T ( id # e xp # e xp ) + +

P R I M ( i d ) — (ф e xp ) - p-j-

RLET( list( id ф exp) # exp) +-)- OP( id # num # list( exp )) + + C LO S URE( exp ф environment) + + S USP( exp ф environment); dec Eval d e c Apply dec ArityOf d ec FunOf

: exp Ф e n v i r o n m e n t e xp ; : e xp ф e x p - > e xp ; : id -> n u m ; : i d - > ( l i s t ( e xp ) - > e xp ) ;

----------- E v a l ( A P P ( E l ,

E 2 ), e n v )

----------- E v a l ( e & I N T ( _ ), _ ) -----------E v a l ( V A R ( v ), e n v ) ----------- E v a l ( P R I M ( p ) , _ ) -----------E v a i ( e & O P ( _ ), - ) ----------- E v a l ( L E T ( v , E l , E 2 ), e n v ) ----------- E v a l ( e & L A M ( _) ----------- E v a l ( e & C L O S U R E * •---------- E v a l * S U S P * E, e n v ), ----------- E v a l * R L E T * d e f s , E ), w h e r e N e w E n v = = e n v H—

env)

< =

A p p l y * E v a l * E 1, e n v ) , S U S P * E2, e n v ) ) ; < = e ; < = E v a l * e n v || v, e n v ) ; < = O P ( p , A rityO f(p), nil); < = e ; < = E val * E2, en v : + : ( v, S U S P * E l, env ) ) ; < = CLOSURE*e, env);

* n, S U S P ( e , N e w E n v ) ) , d e f s ) ; -------A p p l y * C L O S U R E * L A M * v , В ), e n v ), A ) < = E v a l * B, e n v : + : ( v, A ) ) ; -------A p p l y * O P * p, Р А С , a r g s ), A ) < = ( If Р А С = 1 t h e n ( F u n O f * p ) ) * a r g l i s t ) e l s e P R I M * O P * p, Р А С - 1, a r g l i s t ) ) ) w h e r e argl ist = = arg s ( ) [ A ] ;

----------- A r i t y O f * " + " ) < = ----------- A r i t y O f * " - " ) < = ----------- F u n O f * " + " ) < =

2 ; 2 ;

lam bda [ x , y] = > where (INT* a),

------ F u n O f *

)

< =

IN T* a + b ) INT* b ) ) = = (

E v a l * x, e m p t y ) , Eval* y, empty ) ) ;

lam bda [ x , y ] = >

INT* a — b ) w h e r e ( I N T * a ), I N T * b ) ) = =

( E v a l * x , e m p t y ), E v a l* y, e m p ty ) ) ;

Р и с . 9. 3. М о д и ф и ц и р о в а н н ы й и н т е р п р е т а т о р .

Чтобы увидеть, что оба механизма эквивалентны, рассмотрим работу Eval в обоих случаях. Для задержек имеем Eval(SU SP(E, e n v l), env2 ) —> Eval( E, env’ ) Для замыканий имеем Eval( APP(CLOSURE(LAM( ®, E), envl), ? ), env2 ) —*■Apply( Eval( CLOSURE( LAM( , E ), envl), env2), CLOSURE(LAM(®, ?), env2))

222

Часть II. Глава 9

—> Apply( CLOSURE( LAM( , Е), e n v l), CLOSURE(LAM( ®, ? ), env2)) —>■Eval( E, envl : + : ( ®, CLOSURE( LAM( ® , ? ), env2)) = Eval( E, envl )) поскольку является пустым идентификатором и, следова­ тельно, не расширяет контекст. Заметим, однако, что если аргумент в теле функции встре­ чается больше одного раза, то для каждого вхождения аргу­ мента требуется вычисление задержки или применение соответ­ ствующего замыкания к пустому параметру. Таким образом, требуется повторное вычисление аргумента всякий раз, когда нам нужно получить его значение, и, следовательно, мы имеем вызов по имени. Чтобы реализовать ленивое вычисление, мы должны реализовать вызов по необходимости. Для этого тре­ буется разделять не только выражения аргументов, но и резуль­ таты вычислений этих выражений. Механизмы, реализующие вызов по необходимости, рассмотрены в следующем подразделе. 9.3.1. Реализация вызова по необходимости В гл. 6 мы рассмотрели следующее лямбда-выражение: ( Хх. + х х ) Е и показали, как с помощью контекста можно избежать созда­ ния двух копий Е. В нашем интерпретаторе мы имеем тот же эффект, так чго после первого применения в контексте будут существовать две ссылки на одно и то же выражение, т. е. выражение является разделяемым. Проблема, однако, заключается в том, что когда Е действи­ тельно приводится к слабой заголовочной нормальной форме (после вычисления левого аргумента функции + , например), контекст не изменяется. Это означает, что вычисление правого аргумента функции + приведет к повторному вычислению Е. Поэтому несмотря на тот факт, что мы создаем только одну копию выражения аргумента, нам необходимы повторные вы­ числения Е, так что интерпретатор реализует вызов по имени. Хотя может показаться, что для замещения в контексте за­ держки ее значением нам требуется что-то вроде оператора присваивания, более удовлетворительное (с точки зрения функ­ циональности) решение состоит в использовании того факта, что в стандартной реализации языка Норе конструкторы сами вызываются по необходимости. Хитрость здесь в том, чтобы вставить вызов функции Е\а1 внутрь каждой задержки: --------Eval(A PP(El, Е2 ), e n v )< = Apply(Eval(El, env), SUSP( Eval( E2, e n v )));

Методы интерпретации

223

(при этом требуется очевидная модификация ехр для измене­ ния аргументов SUSP-конструктора). Аргумент SUSP-конструк­ тора теперь является выражением, которое не будет вычис­ ляться, пока его значение действительно не потребуется, а после вычисления это значение станет разделяемым. Правило для функции Eval, применяемой к конструктору SUSP, прини­ мает следующий вид: --------Eval(SU SP(E), _ ) < = Е ; Теперй давайте вернемся к нашему простому примеру. Когда значение замыкания для Е потребуется в первый раз (т. е. в процессе вычисления одного из аргументов функции -)-), вы­ ражение внутри задержки будет вычислено. При вычислении второго аргумента функции -f- задержка будет повторно най­ дена в контексте, но на этот раз выражение внутри задержки будет уже в слабой заголовочной нормальной форме. Поэтому, хотя мы дважды ищем замыкание в контексте, вычисление вы­ ражения его аргумента будет проведено только один раз. Это дает нам вызов по необходимости Теперь посмотрим, что произойдет при использовании лени­ вой реализации языка Норе вместо стандартной. Вернемся к энергичному интерпретатору из разд. 9.1 и к правилу для функции Eval, аргументом которой является применение функ­ ции: --------Eval(A PP(El, Е2), e n v )< = Apply( Eval(El, env), Eval(E2, env)); Мы видели, что E2 будет вычислено до применения функции, поскольку в стандартной реализации языка Норе функции вы­ зываются по значению. При использовании ленивой реализации языка Hope Е2 не будет вычислено немедленно, поскольку вы­ ражение Eval(E2, env) является параметром функции Apply, который передается по необходцмости. В результате контекст будет содержать «задержанные» вызовы функции Eval. Эти вызовы будут выполнены только при интерпретации строгих операторов, таких, как --------FunOf( " + " ) < = lambda [INT(a), IN T (b )]= > INT(a + b); При применении этой функции выражения аргументов должны быть вычислены, чтобы выполнить сопоставление с образцом, и именно в этой точке задержанные вызовы Eval будут выпол­ нены. Более того, эти задержанные вызовы будут автоматически заменены значениями INT(a) и INT(b), что соответствует вызову по необходимости. Поэтому, хотя приведенное выше

224

Часть II. Глава 9

правило для Eval выглядит так, как если бы оно реализовывало вызов по значению, на самом деле оно реализует вызов по не­ обходимости! Это наблюдение поведения интерпретаторов ставит интерес­ ный вопрос: как можно реализовать вызов по значению, ис­ пользуя ленивый определяющий язык. Ответ состоит в том, что это невозможно сделать без использования некоторых хитрых искусственных приемов для «форсирования» вычисления аргу­ мента. Одним из таких приемов является сравнение аргумента с самим собой: ■ ------- Eval(APP(E, A), e n v )< = let E = = E v a l(A , env) in if E = E then Apply( Eval( F, env), E) else Apply) Eval(F, env), E); Однако даже это не будет работать, если реализация исполь­ зует тот факт, что выражение Е = Е всегда истинно незави­ симо от значения Е. (Если мы определяем = как строгую функцию, тогда _L = _L должно давать J_, а не true. Другими словами, оба аргумента = должны в этом случае вычисляться, и, значит, приведенное выше правило для Eval будет рабо­ тать.) Все эти рассуждения можно обобщить следующим образом. Используя полностью энергичный определяющий язык (даже без ленивых конструкторов), мы можем без проблем интерпре­ тировать вызов по значению (хотя не сможем построить цикли­ ческий контекст, достаточный для поддержки произвольных ре­ курсивных определений). Мы можем также интерпретировать вызов по имени с помощью задержек, но не можем выполнять вызов по необходимости, поскольку не можем явно переписы­ вать контекст. Если определяющий язык поддерживает лени­ вые конструкторы и энергичные функции (как это сделано в стандартной реализации языка Норе), тогда мы можем реали­ зовать и вызов по значению, и вызов по имени, и вызов по не­ обходимости, используя «энергичные» свойства функций и «ле­ нивые» свойства конструкторов. Если определяющий язык пол­ ностью ленивый (как ленивая реализация Норе), тогда мы можем интерпретировать вызов по имени (используя приведен­ ную выше оригинальную версию SUSP-конструктора) и вызов по необходимости, но вызов по имени возможен, только если мы явным образом форсируем вычисление выражений с по­ мощью строгих операторов, таких, как = . Теперь становится понятно, почему интерпретатор является очень ненадежным средством описания языка. Интерпретатор о многом может сказать, если мы в деталях понимаем семан-

Методы интерпретации

225

тику определяющего языка. Однако если мы неправильно пони­ маем этот язык, то не сможем правильно понять семантику определяемого языка. Ситуация значительно ухудшается, если интерпретатор для языка, скажем, L написан на самом языке L (такой интерпретатор называется метациклическим). В этом случае из правила для вычисления применения функций мы не сможем сказать ничего о механизме вызова. Все это отно­ сится не только к семантике вызова функций. Каждая из кон­ струкций определяемого языка с необходимостью реализуется в терминах конструкций определяющего языка, так что если мы недостаточно хорошо понимаем эти конструкции, то не сможем полностью понять семантику определяемого языка. Именно по этой причине семантика языка программирования часто выра­ жается в неоперационном виде. Идея состоит в том, чтобы от­ казаться от определения языка в терминах другого языка и вы­ ражать семантику языка в терминах точных математических понятий, что особенно подходит для функциональных языков. Это уводит нас от операционной семантики в сферу денотацион­ ной семантики, которая определяет язык, явным образом уста­ навливая математическую величину для каждой языковой конструкции, причем эта величина принадлежит хорошо специ­ фицированной области определения. Чтобы дать читателю неко­ торое базовое представление о предмете, в приложении В мы коротко описываем, что такое денотационная семантика. Этот материал предполагает некоторое минимальное понимание тео­ рии доменов и поэтому естественным образом следует за при­ ложением Б. Резюме • Программа на промежуточном коде может быть представ­ лена в абстрактной форме как структура данных. • Интерпретатор приводит абстрактное представление выра­ жения промежуточного кода к слабой заголовочной нормаль­ ной форме. • Значения всех свободных переменных выражения запоми­ наются в особой структуре, называемой контекстом. • Различные механизмы вызова могут быть .реализованы с по­ мощью модификации правила для вычисления применений функции. • Замыкания используются для представления функций; они запоминают значения свободных переменных тела функции пу­ тем включения в себя части контекста. • Замыкания гарантируют статическое связывание; без них мы получаем динамическое связывание. 15



1473

226

Часть II. Глава 9

• Задержки используются для представления невычисленных выражений при ленивой реализации. Задержки также вклю­ чают в себя контекст и могут моделироваться с помощью за­ мыканий. Интерпретация letrec-выражений основана на исполь­ зовании циклического контекста. • Операционная семантика определяемого языка полностью зависит от семантики определяющего языка. Упражнения 9.1. Для каждого из следующих выражений:

(i) (Ах.Ау.х у у)(А х.А у.у) (ii) (let х = 4 in Ay. + х 7)5 (Hi) (Af.Ag.Ax.f g ( + x 1)2)*(Ах.х) а) запишите абстрактное представление выражения в виде терма составных данных языка Норе (типа ехр, определение которого дано на рис. 9.1); б) проследите вычисление выражения с помощью энергич­ ного интерпретатора из разд. 9.2. 9.2. а. Допустим, «контекст» имеет тип list( identifier# exp ). Запишите объявления и определения функций доступа empty, : + + : и ||. б. Определите через используя функцию teduce для списков. в. Каковы будут определения функций empty, :+ : и :-|—И, если контекст представляется в виде функции типа identifier expression (идентификатор ->- выражение) ? 9.3. Расширьте интерпретатор, включив базовый тип truval, булеву функцию > и логический оператор not. Запишите абстрактное представление выражения ( if( not( > 1 3 ) ) ( Ах.х ) ( Ах. 1 х ) )5 и проследите его вычисление. 9.4. Очевидно, нет необходимости формировать задержку, когда выражение аргумента имеет слабую заголовочную нормальную форму или само является задержкой. Предложите оптимиза­ цию интерпретатора, исключающую формирование таких избы­ точных задержек. 9.5. В наших интерпретаторах мы представляем частичное при­ менение примитивных функций в виде троек, описанных в разд. 9.2. а. Можете ли вы предложить другое представление, исполь­ зующее замыкания? б. Каковы преимущества представления из разд. 9.2 с точ­ ки зрения пользователя интерпретатора? (Подсказка: рассмот-

Методы интерпретации

227

рите случай, когда результатом программы является частичное применение примитива.) 9.6. Рассмотрим правило для функции Eval в случае RLET-выражений для энергичного интерпретатора из разд. 9.2. Здесь мы предполагаем, что каждое определение вводит функцию (мы встречали только замыкания в сконструированном цикли­ ческом контексте). В каком смысле это предположение является избыточно ограничительным? Другими словами, каково общее правило, регулирующее тип выражения в каждом определении для энергичного интерпретатора? Используйте ваш ответ для того, чтобы записать альтернативное определение для Eval в этом случае. 9.7. а. Рассмотрим семейство функций кортежирования tuple-n, п ^ О, которые использовались в предыдущей главе для пред­ ставления составных данных. Помня, что п может принимать любое значение ( ^ 0 ) , предложите, как эти функции можно представить в абстрактном синтаксическом дереве промежу­ точного кода и как применения этих функций могут обраба­ тываться интерпретатором. (Предложение: ’введите новый тип данных AllPrims, который содержит по одному конструктору для каждого примитива, например: d a t a AllPrims = = plus 4 —F minus - \ —(- . . . ; ) б. Запишите новое правило для функции FunOf в случае примитивной функции индексирования кортежей index для выбранного вами представления кортежей. в. Объясните, как ленивые конструкторы могут быть вклю­ чены в энергичный интерпретатор, представленный на рис. 9.2. (Подсказка: рассмотрите дополнительное семейство примити­ вов lazytuple-n( п ^ 0 ), подобное семейству tuple-n, за исклю­ чением того, что каждая такая примитивная функция не яв­ ляется строгой по отношению ко всем своим аргументам.)

15*

Глава 10 РЕАЛИЗАЦИЯ НА ОСНОВЕ СТЕКОВSECD-МАШИНА

Материал первой части этой книги позволяет читателю по­ нять концепции функционального языка и дает представление о математических основах этих концепций. Описаны некоторые свойства, необходимые для системы, вычисляющей функцио­ нальные выражения, например сохранение прозрачности ссылок и корректная реализация порядка вычислений, выбранного для данного языка. В предыдущей главе мы рассмотрели интерпре­ тацию функциональных языков и описали интерпретаторы, ко­ торые сами написаны на функциональном языке. Это позволяет понять, как взяться за конструирование и реализацию функ­ ционального языка, чтобы обеспечить строгую спецификацию высокого уровня Однако операционные характеристики таких интерпретаторов основываются на возможностях того языка, на котором написан сам интерпретатор, — вспомните обсужде­ ние «энергичности» или «ленивости» интерпретатора, написан­ ного на энергичном или ленивом языке. В этой главе мы начи­ наем обсуждение удобных абстрактных машин для выполнения функциональных языков, использующих модель вычислений, основанных на контексте. Мы рассмотрим наиболее общую реализацию, основанную на контексте, которая интерпретирует выражения ^-исчисления. Как мы видели, Х-исчисление имеет достаточную мощность для представления любого функционального языка и поэтому обес­ печивает хорошую основу для построения практических реа­ лизаций. Мы видели также, как функциональные языки транс­ лируются в такую форму, чтобы было возможно принять некото­ рый вариант ^.-исчисления в качестве удобного промежуточного кода для их реализации. Первый интерпретатор этого типа был предложен в [59]. Он использует четыре стека, обозначае­ мые S, Е, С и D для обеспечения механического вычисления Л-выражений. В разд. 10.1 мы рассмотрим энергичную версию

Реализация иа основе стеков — SECD-машина

229

этой так называемой SECD-машины, а в разд. 10.2 опишем модификации, необходимые для реализации ее ленивой версии. Наконец, доказательство ее корректности будет дано в разд. 10 3. Для установления корректности реализации тре­ буется по меньшей мере формальное доказательство того, что семантика ее модели вычислений эквивалентна семантике язы­ ка. (В общем случае необходимо также доказать, что формаль­ ная спецификация реализации соответствует семантике модели, но это относительно простая задача, если интерпретатор напи­ сан на функциональном языке, который сам может отражать семантику модели Следовательно, это доказательство рассмат­ риваться не будет) Доказательство корректности, согласно Плоткину 172], влечет за собой обеспечение формальной семан­ тики работы SECD-машины и затем показывает ее эквивалент­ ность семантике (3-редукции Оно дано для энергичной машины, но модификации, требуемые для ленивой версии, достаточно просты. 10.1. SECD-машина SECD-машина Лэндина использует для вычисления Я-выражений четыре стека. Ее работу очень легко описать нефор­ мально Строгое описание работы SECD-машины основано на представлении ее состояния в зависимости от содержимого ее четырех стеков и заключается в описании переходов машины из одного состояния в другое. Важность SECD-машины состоит в том, что она лежит в основе многих методов практической реализации. Ясно, однако, что без оптимизации SECD-машина является далеко не самым эффективным средством реализации функционального языка. Начнем с описания энергичной системы, в которой пара­ метры передаются по значению, т. е. той, которая реализует вычисление аппликативного порядка Я-выражений. В дальней­ шем мы покажем, как эта система может быть обобщена для обеспечения ленивого вычисления Энергичную и ленивую вер­ сии SECD-машины часто называют машиной, управляемой данными, и машиной, управляемой запросами, соответственно. .Вспомним, что выражения в Я-исчислении имеют следующий БНФ-синтаксис; (ехр) ::== Я(iId),{exp) | (id) | (exp) (exp)| ((exp)) | (con) где (id) представляет идентификаторы, a (con) — произвольный набор констант, таких, как атомы и примитивные функции. Нам также понадобится рассмотреть замыкания в качестве средства представления Я-абстракций вместе с соответствующими

230

Часть II. Глава 10

связями их свободных переменных (см. гл. 9). Замыкания будут записываться в виде [id, exp, env], где id — это иденти­ фикатор связанной переменной Я-выражения, ехр-—это тело Я-выражения, a env-—контекст, содержащий связи свободных переменных тела. Каждый элемент контекста — это пара вида (id, value), где для энергичной SECD-машины value является выражением в слабой заголовочной нормальной форме (СЗНФ). Состояние SECD-машины — это четырехэлементный кортеж (5, Е, С, D), где 5 — это стек объектов, используемый при вы­ числении рекурсивных выражений, Е — это контекст, т. е. спи­ сок пар вида идентификатор — объект, С — это управляющая строка, т. е. оставшаяся часть вычисляемого выражения, D — это дамп, т. е. предыдущее состояние, используемое для воз­ врата из вызова функции. Работа машины описывается в терминах переходов из од­ ного состояния в другое. Переходы мы определим с помощью функции переходов из одного состояния в другое в следующем разделе. Эта функция может быть использована для формаль­ ного определения операционной семантики реализации и дана для случая аппликативного порядка вычислений. В следующем разделе будет описана модификация, требуе­ мая для поддержки ленивого вычисления и, таким образом, определена ленивая семантика. Работа машины каждого типа проиллюстрирована примерами. Перед тем как дать определение функции переходов, мы введем некоторую систему обозначений. Функции-селекторы bv, body, rator и rand для работы с компонентами составных вы­ ражений определяются следующим образом: bv( Хх.Е) = х body( Хх.Е) = Е rator( ElE2) = El rand( Е\Е2) = Е2 Каждый из компонентов состояния S, Е, С, D можно рас­ сматривать как стек или как список, где функция push соот­ ветствует функции cons (которую мы записываем с помощью инфиксной функции ::), TOS соответствует hd и pop соответ­ ствует tl (возможно, вместе с присвоением головы списка не­ которому накопителю). Мы чаще будем использовать представ­ ление в виде списков, и в примерах, показывающих последова­ тельность изменения состояний, списки у нас растут влево, как это принято для операции cons, т. е. самый левый элемент списка считается вершиной соответствующего стека.

Реализация на основе стеков — SECD-машина

231

10.1.1. Функция переходов Мы дадим сейчас спецификацию функции переходов для SECD-машины, реализующей вычисление аппликативного по­ рядка. Реализующая эту функцию программа на языке Норе t y p e id e n tif ie r d a ta exp

= = ch ar; = = I D ( id e n tifie r) + + LAM ( char # exp ) + + A P P ( exp ф exp ) -1—p

data WHNF

= = IN T (nu m )+ -P P R IM ( W H N F -> W H N F ) + + C L O S U R E ( exp # ch ar # li s t ( char # W H N F ) ) ;

ty p e ty p e ty p e ty p e

S ta c k = = E n viro nm en t = = C ontrol = = Dump = =

type S ta t e

li s t ( W H NF ) ; li s t ( char ф W H N F ) ; li s t ( e x p ) ; li s t ( S ta c k # E nv iro n m en t # C o n t r o l ) ;

= = ( S tack Ф E n v iron m en t Ф C ontrol ф Dump ) ;

dec LookUp : id en tifier # E nv iro n m en t -> W H N F ; ---------LookUp( il , ( i2, W ) :: E ) < = = if il = i2 th en W else LookUp( il , E ) ; d ec E v a lu a t e : S t a t e -> WH N F ; ---------E v a lu a te ( R e s u l t :: S, E, nil, nil ) < — R esu lt: ——— E v a lu a t e ( x :: S, E, nil, ( S I , E l, C l ) :: D1 ) < = E v a lu a t e ( x :: S I, E l , C l, D1 ) ; --------- E v a lu a t e ( S, E, I D ( x ) : : C , D ) < = E v a lu a te ( LookUp( x, E ):: S, E, C, D ) ; ---------E v a lu a te ( S, E, LAM( bv, body ) :: C, D ) < = E v a lu a te ( C L O S U R E ( body, bv, E ):: S, E, C, D ) ; ------

E v a lu a t e ( C L O S U R E ( body, bv, E l ) :: ( a r g :: S ), E, @ < = E v a lu a te ( nil, ( bv, a rg ):: E l , [body], ( S , E, C ) : : D ) ;

C, D )

---------E v a lu a t e ( P R IM ( f ) :: ( a rg :: S ), E, в :: C, D ) < = E v a lu a te ( f ( a r g ) : : S , E, C, D ) ; ■-------- E v a lu a t e ( S, E, A P P ( f u n , a r g ) : : C , D ) < = E v a lu a t e ( S, E, a r g :: ( fun :: ( в :: C ) ) , D ) ; Рис. 10.1. Функция переходов для S E C D -машины аппликативного порядка.

показана на рис. 10.1. Для данного текущего состояния (5, Е, С, D) следующее состояние определяется управляющей стро­ кой С. Существует два случая, которые нужно рассмотреть, причем первый из них имеет несколько вариантов.

232

Часть II. Глава 10

1. Если управляющая строка непустая и первым ее элементом является X, т. е. X — h d ( C ) , то мы имеем следующие четыре варианта: (a) X -— это константа или идентификатор. Величина, свя­ занная с X в текущем контексте, проталкивается в стек S, и объект X удаляется из управляющей строки С. В случае если X — это константа, величиной X является сама эта константа. Величину X с учетом текущего контекста Е будем обозначать valueoi ( X, Е ). Таким образом, следующее состояние опреде­ ляется в виде ( valueof(X, E)::S, Е, ЩС), D) (valueof( К, Е ) — К для константы К при любом контексте Е). (b) X — это Я-абстракция. В этом случае мы формируем замыкание, содержащее связанную переменную Х-абстракции, тело ^.-абстракции и контекст, включающий связи свободных переменных тела Л-абстракции (все происходит точно так же, как было описано в предыдущей главе). Следующее состояние имеет вид U M * ) , body(X), E]::S, Е, tl(C), D) Заметим, что при корректной реализации для замкнутых вы­ ражений (без свободных переменных) все свободные перемен­ ные, встречающиеся в теле, должны иметь связанные с ними значения в текущем контексте, поскольку мы приводим выра­ жение промежуточного кода к слабой заголовочной нормальной форме. Конечно, при отсутствии свободных переменных в Х-выражении нет никакой необходимости формировать замыкание. В этом случае ^-выражение может быть непосредственно при­ менимо к своему аргументу, если он есть, или возвращено в качестве результата немедленно. Заметим, однако, что целью SECD-машины не является обеспечение эффективной практиче­ ской реализации. Мы предоставляем читателю возможность самому придумать необходимую оптимизацию в качестве про­ стого упражнения. (c) X = FA, т. е. X — это применение выражения F (функ­ ции) к выражению А (аргументу). При энергичной реализации выражения А и F вначале вычисляются, а затем величина F применяется к величине А. Это выполняется с помощью замены элемента FA в управляющей строке на три элемента A, F, где @ — это специальный символ применения, который (как увидим далее) при своем появлении в начале управляющей строки вызывает применение верхнего элемента стека к эле­ менту, находящемуся непосредственно под ним. Таким образом,

Реализация на основе стеков — SECD-машина

233

следующее состояние имеет вид (S, Е, A: :(F ::(@::tl(C))), D) (d) X = @, где @ — это специальный символ применения. Выражение в вершине стека, скажем F, должно быть или при­ митивной функцией, или замыканием в соответствии с ранее определенными преобразованиями состояний. Если стек 5 = = f :: а :: S', то мы имеем два следующих подслучая: (i) Если f — это примитивная функция, она применяется к а (а — это голова хвоста S). Результат этого применения заме­ няет / и а в стеке S, а символ @ удаляется из управляющей строки. Следующее состояние, таким образом, имеет вид S', Е, tl(C), D) (И) Если f — это замыкание [V,B,E'\, то выражение тела (В) вычисляется в контексте Е, дополненном связью V с а. Однако перед тем как сделать это, состояние машины нужно запомнить, чтобы можно было продолжить работу после того, как вычисле­ ние В закончится. Это состояние представляет собой текущее состояние машины (кортеж из четырех элементов) с удален­ ными верхним элементом управляющей строки и двумя верх­ ними элементами стека. Новый стек пуст, новый контекст пред­ ставляет собой контекст применяемого замыкания, дополненный связью идентификатора связанной переменной с выражением аргумента, а новая управляющая строка состоит из единствен­ ного элемента. Поэтому следующее состояние имеет вид (( ), ( V, а):: Е', В, (S', Е, tl(C), D)) 2. В противном случае управляющая строка С пуста, поэтому вычисление текущего (под) выражения считается законченным и его результатом является единственный элемент стека S (для синтаксически корректных выражений). Состояние, хранящееся в вершине дампа D, восстанавливается, а только что получен­ ный результат проталкивается в новый стек. Если D — (S', Е', С', D'), то следующее состояние имеет вид ( h d ( S ) :: S', Е', С', D ' ) На основе приведенной спецификации можно легко написать базовую реализацию энергичной SECD-машины на языке Норе, определив тип данных для представления состояния машины и функцию, реализующую переходы между состояниями. Про­ грамма, показанная на рис. 10.1, обеспечивает спецификацию, которая является не только краткий и ясной, но и выполнимой. Заметим, что в этой программе идентификаторы для про­ стоты представляются одним символом. Заметим также, что

234

Часть II. Глава 10

тип данных для представления дампа определен как список троек, а не тетрад. Последняя альтернатива также была совер­ шенно правильной при условии включения пустого конструктора дампа в соответствующее определение данных (сравните с кон­ структором nil для списков). Результатом программы в действи­ тельности является СЗНФ, а не «конечное» состояние. Вслед­ ствие рекурсивной природы функции Evaluate, однако, тип объ­ екта, возвращаемого этой функцией, полностью зависит от первого из определяющих ее уравнений. Легко изменить это урав­ нение таким образом, чтобы функция Evaluate возвращала не СЗНФ исходного выражения промежуточного кода, а полное конечное состояние SECD-машины. В этом случае тип Evaluate был бы State-»-State. Теперь мы проиллюстрируем работу машины, показав после­ довательность переходов между состояниями при вычислении выражения twice succ 0. Затем расширим способности нашей машины, научив ее обрабатывать другие примитивные конструкции, необходимые в практических функциональных языках программирования, — условные выражения и рекурсию. Поскольку рекурсия может быть выражена в й-исчислении с помощью У-комбинатора, мы увидим, как машина обрабатывает применения рекурсивно­ определенных функций Однако мы увидим, что в этом случае для энергичной SECD-машины имеют место определенные трудности. 10.1.2. Пример вычисления Выражение, вычисление которого будет рассмотрено в каче­ стве примера, имеет вид twice succ 0, где'функция twice опре­ делена в виде twice — kf.Xx.f(fx), a succ — это уже известная нам примитивная функция «следующий за», определенная на множестве целых чисел. Итак, мы покажем, как SECD-машина вычисляет выражение (й f.X x.f (f x))su cc 0 SECD-машина в начальном состоянии имеет это выражение в качестве единственного элемента своего управляющего стека, а стеки S, Е и D не содержат элементов, т. е. являются пустыми. Последующие состояния машины показаны на рис. 10.2. Каждое состояние получается из предыдущего в соответствии со специ­ фикацией, данной для функции переходов в предыдущем разделе. ° Это выражение детально определено в следующем разделе. — П рим , п ер ев.

E

S О

() succ, 0 If, Хх. Ц f

() [*. H f X),

[x, f ( f x ) ,

() () ( ) 0

succ, 0 1

succ, 1 2 2

х) , i )], succ, (f

0

= su cc) ]

(/ =

succ

)],

(f= 0

() (X= (x ~

{ ( X f .k x . f U x )) succ 0 ) (( X f.X x .fi f x )) succ ) ® ( ( X f.X x .f( f x ) ) succ ), ® succ, X f . X x . f if x ), @, @ 0,

X f . X x . f if x ) , @, в

@ su cc ) succ )

0, 0, ( x = 0, ( x = 0, ( x ~ 0, ( x = Q, ( x = 0, ( x = 0,

( )

X x .f( f x )

() @

/ = succ ) f = succ ) f = succ ) f = succ ) f = succ ) f = shcc )

f U x) f x, f, e X, f, @ , f, @ f, f, @

f == s u c c ) / = succ )

@

@,

f,

f. e

() ()

®

() () () () () ()

(0 , ( ) . « , ( ) ) ( 0, ( ) . • . ( ) ) () (().(),()■()) ( ( ( ( ( ( ( (

( ) ( ) ( ) ( ) ( ) ( ) ( ) )

, , . , . , ,

( ( ( ( ( ( (

) ) ) ) ) ) )

. , . , . . ,

( ( ( ( ( ( (

) ) ) ) ) ) )

, , . , . , .

( ( ( ( ( ( (

) ) ) ) ) ) )

) ) ) ) ) ) )

Рис. 10.2. Последовательность состояний SECD-машины аппликативного порядка при вычислении выражения twice succ 0.

Р е а л и за ц и я н а осн ове стек ов — S E C D -м аш и н а

0 0

() () () () () () (f=

D

c

235

236

Часть И. Глава 10

10.1.3. Специальные примитивные операции в SECD-машине Чистое ^.-исчисление без типов является полным в том смысле, что его средствами можно выразить и привести к нор­ мальной форме любое выражение, записанное на функциональ­ ном языке программирования. Однако для практических целей чистое ^-исчисление требуется расширить множеством некото­ рых примитивов. Это относится по меньшей мере к примитиву условного выражения, некоторым примитивным типам данных (например, целым числам), примитивным функциям (например, + , — и т . д.) и, возможно, средствам представления рекур­ сии, отличным от У-комбинатора Альтернативное представле­ ние таких примитивов в виде выражений чистого ^-исчисления является не только малопонятным для программиста, но и неэф­ фективным при выполнении — в противоположность одной опе­ рации (абстрактной) машины, требуемой при выполнении при­ митива. Например, вспомним из гл. 6, что условная функция представляется обычно в виде Хх Xy.Xz.xyz, а логические кон­ станты true и false в чистом ^-исчислении выглядят как Хх.Ху.х и Хх.Ху.у соответственно. Строгие примитивные функции с одним аргументом не со­ ставляют проблемы, а примитивные функции с большим числом аргументов считаются обладающими свойством карринга. Частичное применение функции от п аргументов ( п > 1 ) к m < п объектам дает в результате замыкание; тело приме­ няемой функции не вычисляется, пока все аргументы не будут в наличии Все эти механизмы присутствуют в только что опи­ санной SECD-машине, не считая соответствующих примитивных операций, и для ленивой машины это все, что нам необходимо. Однако в энерхичной реализации возникают некоторые проб­ лемы, связанные с условными выражениями и рекурсией. Их мы сейчас и рассмотрим. Вычисление условных выражений При аппликативном порядке вычислений обработка некото­ рых условных выражений может привести к неопределенному результату. Например, определенное при всех целых значениях а (кроме а — 0) выражение if а ф Othen 1/а else а приведет к аварийному завершению программы при а = 0, так как при аппликативном порядке вычислений выражения обеих ветвей будут вычисляться независимо от результата вычисления преди­ ката (а ф 0) и при вычислении выражения 1/ а произойдет деление на нуль. Это не единственный случай, когда возникают .Проблемы. Вычисление некоторых обычным образом определен­

Реализация иа основе стеков — SECD-машина

237

ных рекурсивных функций также может привести к аварии или к зацикливанию. Например, вычисление выражения факториала, определенное в виде fac{ п ) = if п = 0 then 1 elsen*fac( п — 1 ), приведет к зацикливанию, так как ветвь else будет вычисляться каждый раз независимо от значения предиката (п — 0). Одно решение проблемы заключается в преобразовании ус­ ловных выражений с использованием пустых функций либо без аргументов, либо с одним пустым аргументом *>, если мы хотим остаться в рамках синтаксиса ^.-исчисления, где все функции имеют точно один аргумент. Это стандартный прием, который можно использовать для обеспечения семантики нормального порядка для выражений, которые должны иметь аппликативный порядок вычисления. В частности, приведенное выше условное выражение после преобразования имеет форму (If а ф О then Xdummy.j 1 a else Xdummy.a) any где any может быть любым объектом. (Заметим, что здесь мы вернулись к нормальной (карринговой) префиксной нотации в выражении I/а.) Данный эквивалент условного выражения уже не может привести к аварии программы и является, кроме того, более эффективным, особенно когда выражение отбрасы­ ваемой ветви условия является громоздким и требующим много времени для вычисления (в предельном случае — бесконечно много времени). Данный метод подобен описанному в гл. 9 для реализации вызова по имени. Более распространенный на практике подход к решению проблемы заключается в определении нового типа выра­ жения: (ехр) ::= . . . | cond {exp) (exp) (exp) где аргументами cond являются соответственно then-выражение, else-выражение и выражение предиката. Причина такого стран­ ного порядка аргументов в том, чтобы позволить обрабатывать выражение вида cond Т Е Р как обычное применение функции. В результате данное выражение в начале управляющей строки будет заменено следующими тремя элементами: Р, condTE, Затем значение предиката Р будет помещено в вершину стека S, а выражение condTE окажется в начале управляющей строки. Теперь мы можем расширить спецификацию функции пе­ реходов, включив один дополнительный вариант в случай 1: о П устой П р и м , п еред .

аргумент

обозначается

в

дальнейшем

символом d u m m y . —

238

Часть II. Глава 10

(с) Если X = c o n d B A , тогда следующее состояние имеет вид ( t l ( S ), Е, В С)), D), если hd{S) = true, ( t l ( S ), Е, A::il( tl{ С )), D ), если hd{S) = false. (Для программы, прошедшей на этапе компиляции Проверку типов, других альтернатив быть не может.) Таким образом, если мы имеем выражение вида condBAP, где В, А, Р также являются выражениями, когда значение Р находится в вершине стека, а управляющая строка имеет вид condBA, @, . . . , легко видеть, что расширенная функция пе­ реходов правильно найдет значение этого выражения (если, конечно, Р имеет корректный тип). Можно было бы ввести дополнительный подслучай в случай X = FA при условии F — = cond В, что привело бы к аналогичному результату. Вычисление выражений, содержащих рекурсивные функции Ниже мы рассмотрим два метода обработки рекурсии в SECD-машине аппликативного порядка: метод использования У-комбинатора и метод помеченных выражений. Заманчиво ис­ пользовать У обычным образом для удаления явной рекурсии из выражения, включающего рекурсивную функцию. После этого полученное ^-выражение просто передается для обработки в SECD-машину, которая в конце концов специально построена так, чтобы иметь возможность обрабатывать такие выражения! Таким образом, если рекурсивная функция определяется урав­ нением вида f ( x ) = E ( f , x ) для некоторого выражения Е, мы можем записать f = YXg.Xx.E(g, х ) где У = Xh. ( Хх. h{ х х )) ( Хх. h( х х )). Например, мы можем записать версию функции для вычисле­ ния факториала в виде fac — YXf.Xn. if ( = п 2 ) then 2 else *n(f(pred n)) где pred — это известная нам примитивная функция предше­ ствования на множестве целых чисел. Взаимно рекурсивные определения могут быть реализованы с помощью упаковки этих определений в рекурсивно-определенные кортежи (или списки), как описано в гл. 6. Итак, можем ли мы реализовать рекурсивные функции не­ посредственно как наименьшие фиксированные точки? Ответом на этот вопрос является нет, если мы используем аппликативный порядок вычислений. Проблема, состоит в том, что само­

Реализация на основе стеков — SECD-машина

239

применение замыкания, соответствующее терму хх, приводит к зацикливанию — проверьте это! Однако можно модифициро­ вать определение У, чтобы обойти проблему, определив альтер­ нативный У-комбинатор следующим образом: У' — Xh .(Хх . Л( Яу. х х у )) ( Хх. h( к у . х х у )) У и У' эквивалентны, поскольку один можно получить из дру­ гого с помощью ^-преобразования (У'ч->- ^У). Если теперь вме­ сто У использовать У', самоприменение вычисляется, только когда оно само применяется к аргументу, соответствующему переменной у, например к целому аргументу функции fac. Если применение функции, соответствующей Л, например fac, к этому значению аргумента не включает рекурсивный вызов, самопри­ менение не будет вычислено. Проверьте это тоже! Следует за­ метить, что этот метод, позволяющий обходить зацикливание в вычислении аппликативного порядка, использует совершенно такой же подход, как описанный нами в гл. 9 для достижения семантики нормального порядка. Конечно, не возникает проб­ лемы при использовании нормального У-комбинатора в ленивой SECD-машине, которая без необходимости не будет пытаться вычислить самоприменение. Использование комбинаторов У и У' для обработки рекурсии в SECD-машине является слож­ ным и неэффективным, хотя некоторая оптимизация дана в [16]. Мы не будем здесь больше рассматривать этот вопрос и перейдем к рассмотрению альтернативного метода. Сначала добавим новый синтаксический тип «помеченное выражение» к синтаксису выражения ехр: (ехр) |«, {id).{ехр))) Теперь сделаем два расширения спецификации функции пере­ ходов. Первое включает дополнительную проверку первого элемента управляющей строки X: (е) Если X — это помеченное выражение { { f ( a ) , 0 y :: 5, E, C, D> (g) {S,E, ( MN ) : : C , D y ^ { S , E , N : : M: : @: : C , D y Можно считать, что функция =>- определяет правила переписы­ вания или одношаговые редукции термов, и мы вводим символ =>-* для обозначения f-шаговой редукции, т. е. преобразования, соответствующего t применениям правил переписывания. Таким образом, =$-* определяется через = =>- и =^‘(D) = = ), где D ~ это дамп, / > 1 . Мы также будем обозначать через =>* произвольное положительное число пере•ходов, т. е. произвольное число применений функции =>-. Таким образом, =>-* интерпретируется как «=ф-* для некоторого /». 10.3.2. Теорема эквивалентности Сейчас, наконец, мы можем определить функцию вычисления Eval для SECD-машины. Интуитивно эта функция представляет загрузку машины термом из управляющей строки, работу ма­ шины с этим термом и затем выгрузку результата из стека ма­ шины. Таким образом, Eval формально определяется в терми­ нах двух вспомогательных-функций Load, (загрузка) и Unload (выгрузка) следующим образом: Eval{ М) = N

248

Часть II. Глава 10

тогда и только тогда, когда Load(M)=$-*D и N = Untoad(D) для некоторого дампа D, где Load и Unload определяются в виде Load(M) — (nil, ф, М, nit) Unload((Cl, ф, nit, nil)) — Real(Cl) Гораздо проще определить семантическую функцию eval для абстрактного вычисления термов с помощью (3-редукции и б-правил: eval{a) ==а, если а — константа, eval( l x . М ) = l x . М, eval(MM) = eval([N'jx] М! ), если eval( М ) = ( l x . М!) и eval( N ) = N' f(a), если eval(M) = f и eval( N ) = a. Чтобы сравнить эти функции вычисления, необходимо иметь возможность соотнести значения выражений, выдаваемые се­ мантической функцией eval, с последовательностями переходов SECD-машины из одного состояния в другое. Мы считаем зна­ чением замкнутого терма его СЗНФ (другой, единственный терм) вместе с целым числом, соответствующим числу редук­ ций, необходимых для приведения этого терма к СЗНФ. Это число, называемое временем, на единицу больше удвоенного числа шагов редукции. Так, например, значением слабой заго­ ловочной нормальной формы является сама эта СЗНФ за вре­ мя 1. В общем случае мы определяем предикат «М имеет зна­ чение N за время Ь> с помощью индукции по t для замкнутых_ термов М п N следующим образом: ( 1 ) а имеет значение а за время I и ( l x . M ) имеет значение ( l x . M ) за время 1 . (2) Если М имеет значение (l x .M ') за время t, N имеет зна­ чение N' за время t' и [N'/x]M' имеет значение L за время I", то ( M N ) имеет значение L за время t + t' + t" + 1. (3) Если М имеет значение / за время t и N имеет значение а за время t', то, если f ( a ) определено, ( M N ) имеет значение { ( а ) за время / + f + 1 . В противном случае терм не имеет значения. Отсюда можно показать (формально с помощью индукции), что если М имеет значения N, N' за времена t, t', то N — N'

Реализация на основе стеков — SECD-машина

249

и t = t'. Следовательно, определение eval(M) — N, если и только если М имеет значение N за неко­ торое время является корректным. Мы будем использовать порядок, устанавливаемый парамет­ ром t предиката «М имеет значение N за время f» в приведен­ ных ниже индуктивных доказательствах лемм и теоремы, в ко­ торых последовательности редукций соотносятся с переходами SECD-машины из одного состояния в другое. Поскольку имена переменных не меняются в определении этого предиката, ясно, что если М — аМ', то М имеет значение N за время t, если и только если для некоторого N' = a N, М' имеет значение N' за время t. Ясно также, что если eval{M) существует, то это замкнутая величина. Утверждение о корректности SECD-реализации дается в следующем виде: Теорема 10.1. Для любого терма М имеет место равенство E v a l(M )= aeval(M ) В доказательстве этой теоремы используются две леммы, кото­ рые мы сейчас сформулируем. Доказательства этих лемм при­ ведены в разд. 10.3.3. Лемма 10.1. Допустим Е — это о-контекст, (М, Еу является замыканием и М" — значение Real((M, Еу) за время t. Тогда для любых S, Е, С, D при условии D o m ( E ) s FV ( C ) и f < 5 , Е, Му. С, D >=^'«M ', E')::S, Е, С, D) где (М',Е'у — это у-замыкание и Real((M', Е'У) = а М”. Обозначения. Если для некоторого 1, D=>f D', где D' не имеет вид -(Е ( М ) :: S, Е, С, D}, мы имеем ) и имеются два случая. Случай (i). Пусть имеет значение (Xx.Nз) за время и, N2 имеет значение Nt за время v. Тогда М" является значением [ЫДх]Ыъ за время w и t — u-\-v-\-w-\- 1. По предположению индукции существуют и' > ц и о' ^ » такие, что (S, Е, ( М {М2)::С, D)*>(S, Е, М2:: М ,:: @ :: С, D) ^ ' { ( М ' 2, E'2)::S, Е, М { у. @ у.С, D) =►“'< №

К ) ■■{К £ 2>::S, я , @ ::С , D>

Реализация на основе стеков — SECD-машина

251

где Real((M\, Е \ ) ) = а(Хх ■N3) и Real( "'««2, E2)::S, Е, М х:: @ :: С, D) =►“'« /, Е,)::(а, E2)::S, Е, @::С, D) =>«ЛГ, 0 > ::5 , Е, С, D) Отсюда мы имеем /' = и' -f- v' + 2 > t и ) за время v ^ {t — 1 ). Согласно лемме 10.1, (S, Е, М2:: М ,:: @ :: С, D )= ^'((M ', E'2)::S, Е, М, : : @: : С, £ ) где v ' ^ v , (М'2, £ ' ) является у-замыканием и Real^(M2, Е'2}) — аМ2. Если v ' ^ t — 1, мы завершаем доказательство, 'поскольку при условии 1 ^ s ^ v' + 1 имеем (S, Е, М :: С, £>=>=^s Ds для некоторого £>s и можем выбрать s = t. Поэтому до­ пустим, что v'