=2

Типы в языках программирования

Бенджамин Пирс

Contents

Предисловие

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

Предполагаемая аудитория

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

Цели

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

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

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

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

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

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

Структура книги

В части I обсуждаются бестиповые системы. Основные понятия абстрактного синтаксиса, индуктивные определения и доказательства, правила вывода и операционная семантика сначала вводятся в контексте чрезвычайно простого языка для работы с числами и логическими значениями, а затем повторяются для бестипового лямбда-исчисления. В части II рассматривается простое типизированное лямбда-исчисление, а также набор базовых языковых конструкций: типы-произведения, типы-суммы, записи, варианты, ссылки и исключения. Вводная глава о типизированных арифметических выражениях плавно подводит к ключевой идее типовой безопасности. В одной из глав (которую можно пропустить) методом Тейта доказывается теорема о нормализации для простого типизированного лямбда-исчисления. Часть III посвящена основополагающему механизму подтипов; она содержит подробное обсуждение метатеории и два расширенных примера. В части IV рассматриваются рекурсивные типы, как в простой изорекурсивной формулировке, так и в более хитроумной эквирекурсивной. Вторая глава этой части развивает метатеорию системы с эквирекурсивными типами и подтипами в рамках математического метода коиндукции. Темой части V является полиморфизм. Она содержит главы о реконструкции типов в стиле ML, о более мощном импредикативном полиморфизме Системы F, об экзистенциальной квантификации и ее связям с абстрактными типами данных, а также о комбинации полиморфизма и подтипов в системах с ограниченной квантификацией. В части VI речь идет об операторах над типами. В первой главе вводятся основные понятия; в следующей разрабатывается Система Fω и ее метатеория; в третьей операторы над типами скрещиваются с ограниченной квантификацией, давая в результате Систему F<:ω; последняя глава представляет собой расширенный пример.

Основные зависимости между главами изображены на рис. 0.1. Серые стрелки означают, что только часть более поздней главы зависит от более ранней.


123 [l]
4 [ur]5 [u]8 [ul]
6 [u]9 [ul] [u]
7 [uu] [ur]11 [u]12 [ul]23 [ulll]
10 [ur] [uurrr] 13 [urr] 14 [ur]15 [u]22 [ul]**[l]24 [u]
**[r]16 [urrr] 18 [u] [urr] 25 [ull] [urrrr] 19 [u]20 @.> [ull] [uuull]26 [ulll] @.> [u] @/_3ex/ [uu]
17 @/^3ex/ [uu] [u]28 [ull] @/_/[urrrr]21 [uul] [ur]
27 @/_/ [uulll] [uurr]
29 [uuuuurrrrr] 30 [l] @.> [uu]31 [ll] [uuurr]32 [l] [ul]

Figure 0.1: Зависимости между главами

Обсуждение каждой языковой конструкции, представленной в книге, следует общей схеме. Сначала идут мотивирующие примеры; затем излагаются формальные определения; затем приводятся доказательства основных свойств, таких как типовая безопасность; затем (обычно в отдельной главе) следует более глубокое исследование метатеории, которое ведет к алгоритмам проверки типов и доказательству их корректности, полноты и гарантии завершения; наконец (опять же, в отдельной главе) приводится конкретная реализация этих алгоритмов в виде программы на языке OCaml (Objective Caml).

На протяжении всей книги важным источником примеров служит анализ и проектирование языковых возможностей для объектно-ориентированного программирования. В четырех главах с расширенными примерами детально развиваются различные подходы: простая модель с обычными императивными объектами и классами (глава 18), базовое исчисление, основанное на Java (глава 19), более тонкий подход к императивным объектам с использованием ограниченной квантификации (глава 27), а также рассмотрение объектов и классов в рамках чисто функциональной Системы F<:ω при помощи экзистенциальных типов (глава 32).

Чтобы материал книги можно было охватить в рамках односеместрового продвинутого курса — а саму книгу мог поднять средний аспирант, — пришлось исключить из рассмотрения многие интересные и важные темы. Денотационный и аксиоматический подходы к семантике опущены полностью; по этим направлениям уже существуют замечательные книги, и эти темы отвлекали бы от строго прагматической, ориентированной на реализацию, перспективы, принятой в этой книге. В нескольких местах упоминаются богатые связи между системами типов и логикой, но в детали я не вдаюсь; эти связи важны, но они увели бы нас слишком далеко в сторону. Многие продвинутые возможности языков программирования и систем типов упомянуты лишь мельком (например, зависимые типы, типы-пересечения, а также соотношение Карри-Говарда); краткие разделы по этим вопросам служат отправными пунктами для дальнейшего самостоятельного изучения. Наконец, не считая краткого экскурса в Java-подобный базовый язык (глава 19), в книге рассматриваются исключительно языки на основе лямбда-исчисления; однако понятия и механизмы, исследованные в этих рамках, могут быть напрямую перенесены в соседние области, такие как типизированные параллельные языки, типизированные ассемблеры и специализированные исчисления объектов.

Требуемая подготовка

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

Читатель должен быть знаком, по меньшей мере, с одним функциональным языком высокого уровня (Scheme, ML, Haskell и т. п.) и с основными понятиями теории языков программирования и компиляторов (абстрактный синтаксис, BNF-грамматики, вычисление, абстрактные машины и т. п.). Этот материал доступен для изучения во множестве замечательных учебников; я особенно ценю <<Основные понятия языков программирования>> Фридмана, Ванда и Хейнса (<<Essentials of Programming Languages>>, Friedman, Wand, and Haynes, 2001), а также <<Прагматику языков программирования>> Скотта (<<Programming Language Pragmatics>>, Scott, 1999). В нескольких главах пригодится опыт работы с каким-либо объектно-ориентированным языком, например, с Java (Arnold and Gosling, 1996).

Главы, посвященные конкретным реализациям программ проверки типов, содержат большие фрагменты кода на OCaml (он же Objective Caml), популярном диалекте языка ML. Для чтения этих глав полезно, но не абсолютно необходимо, знать OCaml; в тексте используется только небольшое подмножество языка, и его конструкции объясняются при их первом использовании. Эти главы образуют отдельную нить в ткани книги, и при желании их можно полностью пропустить.

В настоящее время лучший учебник по OCaml — книга Кузино и Мони (Cousineau and Mauny, 1998). Кроме того, вполне пригодны для чтения и учебные материалы, которые поставляются с дистрибутивом OCaml (http://caml.inria.fr или http://www.ocaml.org).

Для читателей, знакомых с другим крупным диалектом ML, Standard ML, фрагменты кода на OCaml не должны представлять трудности. Среди популярных учебников по Standard ML можно назвать книги Поульсона (Paulson, 1996) и Ульмана (Ullman, 1997).

Примерный план курса

В рамках аспирантского курса среднего или продвинутого уровня можно пройти большую часть материала книги за семестр. На рис. 0.2 показана примерная схема продвинутого курса для аспирантов Пенсильванского университета (две лекции по 90 минут в неделю; предполагается наличие минимальной подготовки в теории языков программирования, однако продвижение очень быстрое).


ЛекцияТемаМатериал
1.Обзор курса; история; административные формальности1, (2)
2.Предварительная информация: синтаксис, операционная семантика3, 4
3.Введение в лямбда-исчисление5.1, 5.2
4.Формализация лямбда-исчисления5.3, 6.7
5.Типы; простое типизированное лямбда-исчисление8, 9, 10
6.Простые расширения; производные формы11
7.Другие расширения11
8.Нормализация12
9.Ссылки; исключения13, 14
10.Подтипы15
11.Метатеория подтипов16, 17
12.Императивные объекты18
13.Облегченная Java19
14.Рекурсивные типы20
15.Метатеория рекурсивных типов21
16.Метатеория рекурсивных типов21
17.Реконструкция типов22
18.Универсальный полиморфизм23
19.Экзистенциальный полиморфизм; АТД24, (25)
20.Ограниченная квантификация26, 27
21.Метатеория ограниченной квантификации28
22.Операторы над типами29
23.Метатеория Fω30
24.Подтипы высших порядков31
25.Чисто функциональные объекты32
26.Лекция про запас 
Figure 0.2: Примерная схема продвинутого аспирантского курса

При составлении курса для студентов или вводного курса для аспирантов можно выбрать несколько способов подачи материала. Курс по системам типов в программировании будет сосредоточен на главах, в которых вводятся различные особенности типовых систем и иллюстрируется их использование; большая часть метатеории и главы, в которых строятся реализации, можно пропустить. С другой стороны, в курсе по основам теории и реализации систем типов можно использовать все ранние главы, возможно, за исключением главы 12 (еще, может быть, 18 и 21), принося в жертву более сложный материал в конце книги. Можно также построить более короткие курсы, выбирая отдельные главы и следуя диаграмме на рис. 0.1.

Книгу также можно использовать как базовый текст более широко организованного аспирантского курса по теории языков программирования. В таком курсе можно потратить половину или две трети семестра на изучение большей части книги, а остаток времени посвятить, скажем, рассмотрению теории параллелизма на основе книги Милнера по пи-исчислению (Milner, 1999), введению в логику Хоара и аксиоматическую семантику (напр., Winskel, 1993) или обзору продвинутых языковых конструкций вроде продолжений или систем модулей.

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

Упражнения

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

Контрольный вопросот 30 секунд до 5 минут
★★Простое≤ 1 час
★★★Средней сложности≤ 3 часа
★★★★Тяжелое> 3 часов


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

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

Типографские соглашения

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

Необычной особенностью этой книги является то, что все примеры были механически проверены при верстке: с помощью особого скрипта из каждой главы извлекались примеры, строилась и компилировалась специальная программа проверки типов, содержащая обсуждаемые в главе особенности, эта программа прогонялась на примерах, и результаты проверки вставлялись в текст.1 Всю сложную работу выполняла при этом система под названием TinkerType, написанная нами с Майклом Левином (Levin and Pierce, 2001). Средства на ее разработку были получены от Национального Научного Фонда по грантам CCR-9701826 <<Принципиальные основы программирования с объектами>> и CCR-9912352 <<Модульные системы типов>>.

Электронные ресурсы

Веб-сайт, посвященный этой книге, можно найти по адресу

http://www.cis.upenn.edu/~bcpierce/tapl

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

Эти реализации представляют собой среду для экспериментов с примерами, приведенными в книге, и для проверки ответов на упражнения. Они написаны с упором на удобство чтения и возможность модификаций. Слушатели моих курсов успешно использовали их в качестве основы как для простых упражнений, так и для курсовых проектов более солидного размера. Эти реализации написаны на OCaml. Бесплатный компилятор OCaml можно найти по адресу http://caml.inria.fr; на большинстве платформ он устанавливается безо всякого труда.

Читателям стоит также знать о существовании списка рассылки Types Forum, посвященного всем аспектам систем типов и их реализации. Список модерируется, что позволяет поддерживать относительную компактность и высокое отношение полезного сигнала к шуму. Архивы рассылки и инструкции для подписчиков можно найти по адресу http://www.cis.upenn.edu/~bcpierce/types.

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

Читатели, которые найдут эту книгу полезной, должны прежде всего быть благодарны четырем учителям — Луке Карделли, Бобу Харперу, Робину Милнеру и Джону Рейнольдсу, — которые научили меня почти всему, что я знаю о языках программирования и типах.

Остальные знания я приобрел в основном в совместных проектах; помимо Луки, Боба, Робина и Джона, среди моих партнеров в этих проектах были Мартин Абади, Гордон Плоткин, Рэнди Поллак, Дэвид Н. Тёрнер, Дидье Реми, Давиде Санджорджи, Адриана Компаньони, Мартин Хофман, Джузеппе Кастанья, Мартин Стеффен, Ким Брюс, Наоки Кобаяси, Харуо Хосоя, Ацуси Игараси, Филип Уодлер, Питер Бьюнеман, Владимир Гапеев, Майкл Левин, Питер Сьюэлл, Джером Вуийон и Эйдзиро Сумии. Сотрудничество с ними послужило для меня не только источником понимания, но и удовольствия от работы над этой темой.

Структура и организация этого текста стали лучше в результате педагогических консультаций с Торстеном Альтеркирхом, Бобом Харпером и Джоном Рейнольдсом, а сам текст выиграл от замечаний и исправлений, авторами которых были Джим Александер, Джош Бердин, Тони Боннер, Джон Танг Бойланд, Дэйв Кларк, Диего Дайнезе, Оливье Данви, Мэттью Дэвис, Владимир Гапеев, Боб Харпер, Эрик Хилсдейл, Харуо Хосоя, Ацуси Игараси, Роберт Ирвин, Такаясу Ито, Асаф Кфури, Майкл Левин, Василий Литвинов, Пабло Лопес Оливас, Дэйв Маккуин, Нарсисо Марти-Олиет, Филипп Менье, Робин Милнер, Матти Нюкянен, Гордон Плоткин, Джон Превост, Фермин Рейг, Дидье Реми, Джон Рейнольдс, Джеймс Рили, Охад Роде, Юрген Шлегельмильх, Алан Шмитт, Эндрю Схонмакер, Олин Шиверс, Педрита Стивенс, Крис Стоун, Эйдзиро Сумии, Вэл Тэннен, Джером Вуийон и Филип Уодлер (я прошу прощения, если кого-то случайно забыл включить в этот список). Лука Карделли, Роджер Хиндли, Дэйв Маккуин, Джон Рейнольдс и Джонатан Селдин поделились исторической перспективой некоторых запутанных вопросов.

Участники моих аспирантских семинаров в Индианском университете в 1997 и 1998 годах и в Пенсильванском университете в 1999 и 2000 годах работали с ранними версиями этой книги; их мнения и комментарии помогли мне придать ей окончательную форму. Боб Прайор и его сотрудники в издательстве <<The MIT Press>> весьма профессионально провели рукопись через все многочисленные стадии публикации. Дизайн книги основан на макросах LATEX, которые разработал для <<The MIT Press>> Кристофер Маннинг.

Доказательства программ настолько скучны, что социальные механизмы математики на них не работают.Ричард Де Милло, Ричард Липтон и Алан Перлис, 1979 …Поэтому при верификации не стоит рассчитывать на социальные механизмы.Дэвид Дилл, 1999 Формальные методы не будут приносить существенных результатов до тех пор, пока их не смогут использовать люди, которые их не понимают.приписывается Тому Мелхэму


1
При верстке перевода это правило не соблюдалось. — прим. перев.
1
При верстке перевода это правило не соблюдалось. — прим. перев.

Chapter 1  Введение

1.1  Типы в информатике

Современные технологии программного обеспечения располагают широким репертуаром формальных методов (formal methods), которые помогают убедиться, что система ведет себя в соответствии с некоторой спецификацией ее поведения, явной или неявной. На одном конце шкалы находятся мощные конструкции, такие как логика Хоара, языки алгебраической спецификации, модальные логики и денотационные семантики. Все они способны выразить самые широкие требования к корректности программ, однако часто очень трудны в использовании и требуют от программистов высочайшей квалификации. На другом конце спектра располагаются намного более скромные методы — настолько скромные, что автоматические алгоритмы проверки могут быть встроены в компиляторы, компоновщики или автоматические анализаторы программ, а применять их могут даже программисты, не знакомые с теоретическими основами этих методов. Хорошо известный пример таких облегченных формальных методов (lightweight formal method) — программы проверки моделей (model checkers) — инструменты для поиска ошибок в таких конечных системах, как интегральные схемы или коммуникационные протоколы. Другой пример, приобретающий сейчас популярность — мониторинг во время исполнения (run-time monitoring), набор приемов, позволяющих системе динамически обнаруживать, что поведение одного из ее компонентов отклоняется от спецификации. Однако же, самый популярный и испытанный облегченный формальный метод — это системы типов (type systems), которым в основном и посвящена эта книга.

Как и многие другие термины, используемые в больших сообществах, термин <<система типов>> трудно определить так, чтобы охватить его неформальное использование в среде разработчиков и авторов языков программирования, но чтобы при этом определение было достаточно конкретным. Вот, например, одно из возможных определений:

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

Некоторые моменты заслуживают дополнительного пояснения. Во-первых, в этом определении системы типов названы средством для рассуждений о программах. Такой выбор слов отражает ориентацию этой книги на системы типов, используемые в языках программирования. В более общем смысле, термин системы типов (или теория типов, type theory) относится к намного более обширной области исследований в логике, математике и философии. В этом смысле типы были впервые формально описаны в начале 1900-х годов как средство избежать логических парадоксов, угрожавших основаниям математики, например, парадокса Рассела (Russell, 1902). В течение двадцатого века типы превратились в стандартный инструмент логики, особенно в теории доказательств (см. Gandy, 1976 и Hindley, 1997), и глубоко проникли в язык философии и науки. В этой области основными достижениями были теория типов Рассела (ramified theory of types) (Whitehead and Russell, 1910), простая теория типов (simple theory of types) Рамсея (Ramsey, 1925) — основа для простого типизированного лямбда-исчисления Чёрча (Church, 1940), конструктивная теория типов (constructive theory of types) Мартина-Лёфа (1973, 1984) и чистые системы типов (pure type systems) Бенарди, Терлоу и Барендрегта (Berardi, 1988, Terlouw, 1989, Barendregt, 1992).

Внутри самой информатики как научной дисциплины изучение систем типов разделяется на две ветви. Основной темой этой книги является более практическая ветвь, в которой исследуются приложения к языкам программирования. Более абстрактная ветвь изучает соответствия между различными <<чистыми типизированными лямбда-исчислениями>> и разновидностями логики, через изоморфизм Карри-Говарда (Curry-Howard correspondence) (§9.4). В обоих сообществах используются аналогичные понятия, системы записи и методы, однако есть важные различия в подходе. Например, исследования типизированного лямбда-исчисления обычно имеют дело с системами, в которых для всякого правильно типизированного вычисления гарантировано завершение, в то время как большинство языков программирования жертвуют этим свойством ради таких инструментов, как рекурсивные функции.

Еще одно важное свойство вышеуказанного определения — упор на классификацию термов (синтаксических составляющих) в соответствии со значениями, которые они порождают, будучи вычисленными. Систему типов можно рассматривать как статическую (static) аппроксимацию поведения программы во время выполнения. (Более того, типы термов обычно вычисляются композиционально (compositionally), то есть тип выражения зависит только от типов его подвыражений.)

Иногда слово <<статический>> добавляется явным образом — например, говорят о <<языках программирования со статической типизацией>>, — чтобы отличить тот анализ, который производится при компиляции, и который мы рассматриваем здесь, от динамической (dynamic) или латентной (latent) типизации в языках вроде Scheme (Sussman and Steele, 1975, Kelsey, Clinger, and Rees, 1998, Dybvig, 1996), в которых теги типов (type tags) используются для различения видов объектов, находящихся в куче. Термин <<динамически типизированный>>, по нашему мнению, неверен (его следовало бы заменить на <<динамически проверяемый>>), но такое употребление уже общепринято.

Будучи статическими, системы типов обязательно консервативны (conservative): они способны однозначно доказать отсутствие определенных нежелательных видов поведения, но не могут доказать их наличие, и, следовательно, иногда вынуждены отвергать программы, которые на самом деле при выполнении ведут себя корректным образом. Например, программа

if <сложная проверка> then S else <ошибка типа>

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

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

Виды некорректного поведения, которые в каждом конкретном языке могут быть устранены при помощи типов, часто называют ошибками типов времени выполнения (run-time type errors). Важно помнить, что набор таких ошибок в каждом язык свой: несмотря на то, что наборы ошибок времени выполнения в разных языках существенно пересекаются, в принципе каждая система типов сопровождается описанием ошибок, которые она должна предотвращать. Безопасность (safety) (или корректность, soundness) системы типов должна определяться по отношению к её собственному набору ошибок времени выполнения.

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

Процедуры проверки типов обычно встроены в компиляторы или компоновщики. Следовательно, они должны уметь выполнять свою работу автоматически (automatically), без ручного вмешательства или взаимодействия с программистом, — т. е., они должны содержать вычислительно разрешимые (tractable) алгоритмы анализа. Однако, это по-прежнему оставляет программисту множество способов повлиять на работу анализатора с помощью явных аннотаций типов (type annotations) в программах. Обычно эти аннотации делают достаточно простыми, чтобы программы было легче писать и читать. В принципе, однако, в аннотациях типов можно закодировать полное доказательство соответствия программы некоторой произвольной спецификации; в этом случае, программа проверки типов превращается в программу проверки доказательств (proof checker). Такие технологии, как Extended Static Checking (<<расширенная статическая проверка>>) (Detlefs, Leino, Nelson, and Saxe, 1998), наводят мосты между системами типов и методами всеобъемлющей верификации программ. Эти технологии реализуют полностью автоматизированную проверку некоторого широкого класса желательных свойств, используя для работы лишь <<достаточно легковесные>> аннотации.

Ещё заметим, что нам прежде всего интересны методы, которые не просто в принципе поддаются автоматизации, но которые обеспечивают эффективные алгоритмы проверки типов. Впрочем, вопрос о том, что именно считать эффективным, остается открытым. Даже в широко используемых системах типов, вроде ML (Damas and Milner, 1982), время проверки в некоторых патологических случаях может быть громадным (Henglein and Mairson, 1991). Имеются даже языки, в которых задача проверки или реконструкции типов не является разрешимой, но в которых есть алгоритмы, ведущие к быстрому останову <<в большинстве случаев, представляющих практический интерес>> (напр., ???).

1.2  Для чего годятся типы

Выявление ошибок

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

На практике статическая проверка типов обнаруживает удивительно широкий спектр ошибок. Программисты, работающие в языках с богатой системой типов, замечают, что их программы зачастую <<сразу начинают работать>>, если проходят проверку типов, причем происходит это даже чаще, чем они сами могли бы бы ожидать. Одно из возможных объяснений состоит в том, что в виде несоответствий на уровне типов часто проявляются не только тривиальные неточности (скажем, когда программист забывает преобразовать строку в число, прежде чем извлечь квадратный корень), но и более глубокие концептуальные ошибки (например, пропуск граничного условия в сложном разборе случаев, или смешение единиц измерения в научном вычислении). Сила этого эффекта зависит от выразительности системы типов и от решаемой программистской задачи: программы, работающие с большим набором структур данных (скажем, компиляторы) дают больше возможностей для проверки типов, чем программы, которые работают лишь с несколькими простыми типами, скажем, научные вычислительные задачи (хотя и тут могут оказаться полезны тонкие системы типов, поддерживающие анализ размерностей (dimension analysis); см. Kennedy, 1994).

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

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

Абстракция

Еще одно важное достоинство использования систем типов при разработке программ — это поддержание дисциплины программирования. В частности, в контексте построения крупномасштабных программных систем, системы типов являются стержнем языков описания модулей (module languages), при помощи которых компоненты больших систем упаковываются и связываются воедино. Типы появляются в интерфейсах модулей (или близких по смыслу структур, таких, как классы); в сущности, сам интерфейс можно рассматривать как <<тип модуля>>, содержащий информацию о возможностях, которые модуль предоставляет — как своего рода частичное соглашение между разработчиками и пользователями.

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

Документация

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

Безопасность языков

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

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

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

С другой стороны, в небезопасных языках часто имеются статические проверки типов, которые <<по возможности>> отлавливают хотя бы самые очевидные огрехи программистов. Впрочем, такие языки не могут считаться и безопасными с точки зрения типов, поскольку в общем случае они не могут гарантировать, что корректно типизированные программы ведут себя корректно — проверка типов в таком языке может предположить наличие ошибок во время выполнения (что, безусловно, лучше, чем ничего), но не доказать их отсутствие.

 Статическая проверкаДинамическая проверка
БезопасныеML, Haskell, Java и т. п.Lisp, Scheme, Perl, Postscript и т. п.
НебезопасныеC, C++ и т. п. 

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

Как правило, достичь безопасности выполнения при помощи только лишь статической типизации невозможно. Например, все языки, указанные в таблице как безопасные, на самом деле производят проверку выхода за границы массивов (array bounds checking) динамически.1Аналогично, языки со статической проверкой типов иногда предоставляют операции (скажем, нисходящее преобразование типов (down-casts) в Java — см. §15.5), некорректные с точки зрения проверки типов, а безопасность языка при этом достигается при помощи динамической проверки каждого употребления такой конструкции.

Безопасность языка редко бывает абсолютной. Безопасные языки часто предоставляют программистам <<черные ходы>>, например, вызовы функций на других, возможно небезопасных, языках. Иногда такие контролируемые черные ходы даже содержатся в самом языке — например, Obj.magic в OCaml (Leroy, 2000), Unsafe.cast в Нью-Джерсийской реализации Standard ML, и т. п. Языки Modula-3 (Cardelli et al., 1989, Nelson, 1991) и C# (Wille, 2000) идут еще дальше и включают в себя <<небезопасные подъязыки>>, предназначенные для реализации низкоуровневых библиотек вроде сборщиков мусора. Особые возможности этих подъязыков можно использовать только в модулях, явно помеченных словом unsafe (<<небезопасный>>).

Карделли (Cardelli, 1996) смотрит на безопасность языка с другой точки зрения, проводя различие между диагностируемыми (trapped) и недиагностируемыми (untrapped) ошибками времени выполнения. Диагностируемая ошибка вызывает немедленную остановку вычисления (или порождает исключение, которое можно обработать внутри программы), в то время как при недиагностируемой ошибке вычисление может ещё продолжаться (по крайней мере, в течение некоторого времени). Пример недиагностируемой ошибки — обращение к данным за пределами массива в языке C. Безопасный язык, с этой точки зрения, — это язык, который предотвращает недиагностируемые ошибки во время выполнения.

Еще одна точка зрения основана на понятии переносимости; ее можно выразить лозунгом <<Безопасный язык полностью определяется руководством программиста>>. Сделаем так, чтобы программисту было достаточно понять определение (definition) языка, чтобы уметь предсказывать поведение любой программы на данном языке. Тогда руководство программиста на языке C не является его определением, поскольку поведение некоторых программ (скажем, тех, где встречается непроверенное обращение к массивам или используется арифметика указателей) невозможно предсказать, не зная, как конкретный компилятор C располагает структуры в памяти и т. п., а одна и та же программа может вести себя по-разному, будучи обработана разными компиляторами. Напротив, руководства по Java, Scheme и ML определяют (с различной степенью строгости) точное поведение любой программы, написанной на этих языках. Корректно типизированная программа получит одни и те же результаты в любой корректной реализации этих языков.

Эффективность

Первые системы типов в информатике появились в 50-х годах в таких языках, как Фортран (Backus, 1981), и были введены для того, чтобы повысить эффективность вычислений путем различения арифметических выражений с целыми и вещественными числами; это позволяло компилятору использовать различные представления чисел и генерировать соответствующие машинные команды для элементарных операций. В безопасных языках можно достичь большей эффективности, устраняя многие динамические проверки, которые иначе потребовались бы для обеспечения безопасности (статически доказав, что проверка всегда даст положительный результат). В наше время большинство высокопроизводительных компиляторов существенным образом опираются при оптимизации и генерации кода на информацию, собранную процедурой проверки типов. Даже компиляторы для языков, в которых нет системы типов самой по себе, проделывают большую работу, чтобы хотя бы частично восстановить информацию о типах.

Информация о типах может принести выигрыш в эффективности в самых неожиданных местах. Например, недавно было показано, что с помощью информации, порождаемой при проверке типов, могут быть улучшены не только решения о генерации кода, но и представление указателей в параллелизированных научных вычислениях. Язык Titanium (Yelick et al., 1998) использует вывод типов для анализа области видимости указателей и способен принимать более эффективные решения на основе этих данных, чем программисты, оптимизирующие программы вручную (это подтверждается измерениями). Компилятор ML Kit с помощью мощного алгоритма вывода регионов (region inference) (Gifford, Jouvelot, Lucassen, and Sheldon, 1987, Jouvelot and Gifford, 1991, Talpin and Jouvelot, 1992, Tofte and Talpin, 1994, Tofte and Talpin, 1997, Tofte and Birkedal, 1998) заменяет большинство вызовов сборщика мусора (а иногда даже все вызовы) операциями управления памятью в стеке.

Другие приложения

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

Все большее значение приобретает использование систем типов в области безопасности компьютеров и сетей. Например, статическая типизация лежит в основе модели безопасности Java и архитектуры автоматического конфигурирования (plug and play) сетевых устройств JINI (Arnold et al., 1999), а также играет ключевую роль в методике Proof Carrying Code (<<кода, содержащего доказательство>>, ???). В то же время, многие фундаментальные идеи, возникшие в среде специалистов по безопасности, повторно используются в контексте языков программирования, и часто реализуются в виде системы анализа типов (напр., ???; и т. д.). С другой стороны, растет интерес к прямому применению теории языков программирования в области компьютерной безопасности (напр., Abadi, 1999; Sumii and Pierce, 2001).

Алгоритмы проверки и вывода типов встречаются во многих инструментах анализа программ, помимо компиляторов. Например, утилита AnnoDomini, анализирующая программы на Коболе на предмет совместимости с проблемой 2000 года, построена на базе механизма вывода типов в стиле ML (Eidorff et al., 1999). Методы вывода типов использовались также в инструментах для анализа псевдонимов (pointer aliasing) (O’Callahan and Jackson, 1997) и анализа исключений (Leroy and Pessaux, 2000).

При автоматическом доказательстве теорем для представления логических утверждений и доказательств обычно используются очень мощные системы типов, основанные на зависимых типах. Некоторые популярные средства работы с доказательствами, включая Nuprl (Constable et al., 1986), Lego (Luo and Pollack, 1992, Pollack, 1994), Coq (Barras, Boutin, Cornes, Courant, Filliatre, Gimenez, Herbelin, Huet, Munoz, Murthy, Parent, Paulin-Mohring, Saibi, and Werner, 1997) и Alf (Magnusson and Nordström, 1994), прямо основаны на теории типов. Констебль (Constable, 1998) и Пфеннинг (Pfenning, 1999) излагают в своих работах историю этих систем.

Растет интерес к системам типов и в сообществе специалистов по базам данных. Это связано с популярностью <<сетевых метаданных>>, использующихся для описания структурированных данных на XML, таких как DTD (Document Type Definitions, XML 1998, ) и других видов схем (таких, как новый стандарт XML-Schema, XS 2000, ). Новые языки для запросов к XML и обработки XML-данных обладают мощными статическими системами типов, прямо основанными на этих языках схем (Hosoya and Pierce, 2000; Hosoya, Vouillon, and Pierce, 2001; Hosoya and Pierce, 2001; Relax, 2000; Shields, 2001).

Совершенно отдельная область приложения систем типов — вычислительная лингвистика, где типизированные лямбда-исчисления лежат в основе таких формализмов, как категориальная грамматика (categorial grammar) (???, и т. д.).

1.3  Системы типов и проектирование языков

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

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

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

Утверждение, что типы должны являться неотъемлемой частью языка программирования, ортогонально вопросу о том, в каких случаях программист должен явно указывать аннотации типа, а в каких случаях их может вычислить компилятор. Хорошо спроектированный статически типизированный язык никогда не будет требовать от программиста вручную выписывать длинные и утомительные декларации типов. Впрочем, по вопросу о том, какое количество явной информации считать чрезмерным, существуют различные мнения. Создатели языков семейства ML тщательно старались свести аннотации к самому минимуму, используя для получения недостающей информации методы вывода типов. В языках семейства C, включая Java, принят несколько более многословный стиль.

1.4  Краткая история

Самые ранние системы типов в информатике использовались для простейшего различения между целыми числами и числами с плавающей точкой (например, в Фортране). В конце 1950-х и начале 1960-х эта классификация была расширена на структурированные данные (массивы записей и т. п.) и функции высшего порядка. В начале 1970-х появились еще более сложные понятия (параметрический полиморфизм, абстрактные типы данных, системы модулей и подтипы), и системы типов превратились в отдельное направление исследований. Тогда же специалисты по информатике обнаружили связь между системами типов в языках программирования и типами, изучаемыми в математической логике, и это привело к плодотворному взаимодействию между двумя областями, которое продолжается по сей день.

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


1870-еначала формальной логикиFrege (1879)
1900-еформализация математикиWhitehead and Russell (1910)
1930-ебестиповое лямбда-исчислениеChurch (1941)
1940-епростое типизированное лямбда-исчислениеChurch (1940), Curry and Feys (1958)
1950-еФортранBackus (1981)
 Алгол-60Naur et al. (1963)
1960-епроект Automathde Bruijn (1980)
 СимулаBirtwistle, Dahl, Myhrhaug, and Nygaard (1979)
 изоморфизм Карри-ГовардаHoward (1980)
 Алгол-68van Wijngaarden, Mailloux, Peck, Koster, Sintzoff, Lindsey, Meertens, and Fisker (1975)
1970-еПаскальWirth (1971)
 Теория типов Мартина-ЛёфаMartin-Löf (1973, 1982)
 Системы F, FωGirard (1972)
 полиморфное лямбда-исчислениеReynolds (1974)
 CLULiskov, Atkinson, Bloom, Moss, Schaffert, Scheifler, and Snyder (1981)
 полиморфный вывод типовMilner (1978), Damas and Milner (1982)
 MLGordon, Milner, and Wadsworth (1979)
 типы-пересеченияCoppo and Dezani-Ciancaglini (1978)
  Coppo, Dezani-Ciancaglini, and Sallé (1979), Pottinger (1980)
1980-епроект NuprlConstable et al. (1986)
 подтипыReynolds (1980), Cardelli (1984), Mitchell (1984a)
 АТД как экзистенциальные типыMitchell and Plotkin (1988)
 исчисление конструкцийCoquand (1985), Coquand and Huet (1988)
 линейная логикаGirard (1987), Girard, Lafont, and Taylor (1989)
 ограниченная квантификацияCardelli and Wegner (1985)
  Curien and Ghelli (1992), Cardelli, Martini, Mitchell, and Scedrov (1994)
 LF (Edinburgh Logical Framework)Harper, Honsell, and Plotkin (1992)
 ForsytheReynolds (1988)
 чистые системы типовTerlouw (1989), Berardi (1988), Barendregt (1991)
 зависимые типы и модульностьMacQueen (1986)
 QuestCardelli (1991)
 системы эффектовGifford, Jouvelot, Lucassen, and Sheldon (1987), Talpin and Jouvelot (1992)
 строчные переменные; расширяемые записиWand (1987), Rémy (1989)
  Cardelli and Mitchell (1991)
1990-еподтипы высших порядковCardelli (1990), Cardelli and Longo (1991)
 типизированные промежуточные языкиTarditi, Morrisett, Cheng, Stone, Harper, and Lee (1996)
 исчисление объектовAbadi and Cardelli (1996)
 полупрозрачные типы и модульностьHarper and Lillibridge (1994), Leroy (1994)
 типизированный язык ассемблераMorrisett, Walker, Crary, and Glew (1998)
Figure 1.1: Краткая история типов в информатике и логике.

1.5  Дополнительная литература

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

В обзорных статьях Карделли (Cardelli, 1996) и Митчелла (Mitchell, 1990b) содержится краткое введение в дисциплину. Статья Барендрегта (Barendregt, 1992) предназначена скорее для читателя, склонного к математике. Объемистый учебник Митчелла <<Основания языков программирования>> (<<Foundations for Programming Languages>>, Mitchell, 1996) описывает основы лямбда-исчисления, несколько систем типов и многие вопросы семантики. Основное внимание уделяется семантике, а не деталям реализации. Книга Рейнольдса <<Теории языков программирования>> (<<Theories of Programming Languages>>, Reynolds, 1998b) — это обзор теории языков программирования, предназначенный для аспирантов и содержащий изящное описание полиморфизма, подтипов и типов-пересечений. <<Структура типизированных языков программирования>> Шмидта (<<The Structure of Typed Programming Languages>>, Schmidt, 1994) развивает основные понятия систем типов в контексте проектирования языков, и содержит несколько глав по обыкновенным императивным языкам программирования. Монография Хиндли <<Основы теории простых типов>> (<<Basic Simple Type Theory>>, Hindley, 1997) является замечательным собранием результатов теории простого типизированного лямбда-исчисления и близкородственных систем. Оно отличается скорее глубиной, нежели широтой охвата.

<<Теория объектов>> Абади и Карделли (<<A Theory of Objects>>, Abadi and Cardelli, 1996) развивает во многом тот же материал, что и настоящая книга, с меньшим упором на вопросы реализации. Вместо этого она подробно описывает применение основных идей для построения оснований теории объектно-ориентированного программирования. Книга Кима Брюса <<Основы объектно-ориентированных языков: типы и семантика>> (<<Foundations of Object-Oriented Languages: Types and Semantics>>, Bruce, 2002) покрывает приблизительно тот же материал. Введение в теорию объектно-ориентированных систем типов можно также найти в книгах Палсберга и Шварцбаха (Palsberg and Schwartzbach, 1994), а также Кастаньи (Castagna, 1997).

Семантические основы как бестиповых, так и типизированных языков подробно рассмотрены в учебниках Гантера (Gunter, 1992), Уинскеля (Winskel, 1993) и Митчелла (Mitchell, 1996). Кроме того, операционная семантика детально описана в книге Хеннесси (Hennessy, 1990). Основания семантики типов в рамках математической теории категорий (category theory) можно найти во множестве источников, включая книги Якобса (Jacobs, 1999), Асперти и Лонго (Asperti and Longo, 1991) и Кроула (Crole, 1994); краткое введение имеется в <<Основах теории категорий для специалистов по информатике>> (<<Basic Category Theory for Computer Scientists>>, Pierce, 1991a).

Книга Жирара, Лафонта и Тейлора <<Доказательства и типы>> (<<Proofs and Types>>, Girard, Lafont, and Taylor, 1989) посвящена логическим вопросам теории типов (изоморфизм Карри-Говарда и т. п.). Кроме того, она включает описание Системы F, сделанное ее создателем, и приложение с введением в линейную логику. Связи между типами и логикой исследуются также в книге <<Вычисление и дедукция>> Пфеннинга (<<Computation and Deduction>>, Pfenning, 2001). <<Теория типов и функциональное программирование>> Томпсона (<<Type Theory and Functional Programming>>, Thompson, 1991) и <<Конструктивные основания функциональных языков>> Тёрнера (<<Constructive Foundations for Functional Languages>>, Turner, 1991) посвящены связям между функциональным программированием (в смысле <<чисто функционального программирования>>, как в языках Haskell и Miranda) и конструктивной теорий типов, рассматриваемой с точки зрения логики. Множество вопросов теории доказательств, имеющих отношение к программированию, рассмотрены в книге <<Теория типов и автоматическая дедукция>> Гобольт-Ларрека и Макки (<<Proof Theory and Automated Deduction>>, Goubault-Larrecq and Mackie, 1997). История типов в логике и философии более подробно описана в статьях Констебля (Constable, 1998), Уодлера (Wadler, 2000), Юэ (Huet, 1990) и Пфеннинга (Pfenning, 1999), а также в диссертации Лаана (Laan, 1997) и в книгах Граттан-Гиннесса (Grattan-Guinness, 2001) и Соммаруги (Sommaruga, 2000).

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


1
Устранение проверок границ массива статическими средствами — хорошо известная цель при проектировании систем типов. В принципе, необходимые для этого механизмы (на основе зависимых типов (dependent types) — см. §30.5) хорошо изучены, однако их использование с соблюдением баланса между выразительной силой, предсказуемостью и разрешимостью проверки типов, а также сложностью программных аннотаций остается сложно задачей. О некоторых недавних достижениях в этой области можно прочитать у Си и Пфеннинга (Xi and Pfenning, 1998, Xi and Pfenning, 1999).

Chapter 2  Математический аппарат

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

2.1  Множества, отношения и функции

Определение 1   Мы пользуемся стандартными обозначениями для множеств: фигурные скобки используются, когда элементы множества перечисляются явным образом ({…}) или когда оно задается выделением (comprehension) из другого множества ({xS ∣ … }), обозначает пустое множество, а выражение S \ T — теоретико-множественную разность S и T (множество элементов S, не являющихся элементами T). Мощность (количество элементов) множества S обозначается |S|. Множество всех подмножеств S обозначается P(S).
Определение 2   Множество натуральных чисел (natural numbers) {0, 1, 2, 3, 4, 5,…} обозначается символом . Множество называется счетным (countable), если между его элементами и натуральными числами существует взаимно-однозначное соответствие.
Определение 3   n-местное отношение (relation) на наборе множеств S1, S2, …, Sn — это множество RS1 × S2 × … × Sn кортежей элементов S1,…, Sn. Если (s1,…,sn) ∈ R, где s1S1, …, snSn, то говорится, что s1, …, sn связаны (related) отношением R.
Определение 4   Одноместное отношение на множестве S называется предикатом (predicate) на S. Говорится, что P истинен на sS, если sP. Чтобы подчеркнуть это интуитивное понятие, мы часто будем писать P(s) вместо sP, рассматривая P как функцию, переводящую элементы S в истинностные значения.
Определение 5   Двуместное отношение R на множествах S и T называется бинарным отношением (binary relation). Мы часто будем писать s R t вместо (s,t) ∈ R. Если S и T совпадают (назовем это множество U), мы будем говорить, что R — бинарное отношение на U.
Определение 6   Чтобы облегчить чтение, многоместные (трех- и более) отношения часто записываются в <<смешанном>> синтаксисе, когда элементы отношения разделяются последовательностью символов, и эти символы вместе образуют имя отношения. Например, отношение типизации для простого типизированного лямбда-исчисления из главы 9 записывается как Γ ⊢ s : T — такая формула означает <<тройка (Γ, s, T) находится в отношении типизации>>.
Определение 7   Областью определения (domain) отношения R между множествами S и T называется множество элементов sS, таких, что (s,t) ∈ R для некоторого t. Область определения R обозначается dom(R). Областью значений (codomain) R называется множество элементов tT, таких, что (s,t) ∈ R для некоторого s. Область значений R обозначается range(R).
Определение 8   Отношение R на множествах S и T называется частичной функцией (partial function) из S в T, если из (s,t1) ∈ R и (s, t2) ∈ R следует t1 = t2. Если, кроме того, dom(R) = S, то R называется всюду определенной функцией (total function), или просто функцией (function), из S в T.
Определение 9   Частичная функция R из S в T определена (defined) на аргументе sS, если sdom(R), и не определена в противном случае. Запись f(x)↑ или f(x) = ↑ означает <<f не определена на x>>, а f(x)↓ означает <<f определена на x>>.

В некоторых главах, посвященных программной реализации систем типов, нам также потребуется определять функции, которые на некоторых аргументах терпят неудачу (fail) (см., например, рис. 22.2). Важно отличать неудачу (частный случай разрешенного, наблюдаемого результата) от расхождения (divergence); функция, способная потерпеть неудачу, может быть либо частичной (т. е., может также расходиться), либо быть всюду определенной (т. е., она всегда либо возвращает результат, либо терпит неудачу) — в сущности, часто нам будет нужно доказывать, что такая функция всюду определена. Если f возвращает неудачу при применении к x, мы будем писать f(x) = неудача.

С формальной точки зрения, функция из S в T, которая может вернуть неудачу, является на самом деле функцией из S в T ∪ {неудача} (мы предполагаем, что неудача не входит во множество T).

Определение 10   Пусть R — бинарное отношение на множестве S, а P — предикат на S. Если из sRs и P(s) следует P(s′), то говорится, что R сохраняет (preserves) P.

2.2  Упорядоченные множества

Определение 1   Бинарное отношение R на множестве S рефлексивно (reflexive), если каждый элемент S связан отношением R с самим собой — то есть, для всех sS, sRs (или (s,s) ∈ R). Отношение R симметрично (symmetric), если для всех s,tS из sRt следует tRs. Отношение R транзитивно (transitive), если из sRt и tRu следует sRu. Отношение R антисимметрично (antisymmetric), если из sRt и tRs следует s=t.
Определение 2   Рефлексивное и транзитивное отношение R на множестве S называется предпорядком (preorder) на S. (Всякий раз, когда мы говорим о <<предупорядоченном множестве S>>, мы имеем в виду какой-то конкретный предпорядок на S.) Предпорядки обычно обозначаются символами  или . Запись s < t означает, что stst (<<s строго меньше t>>).

Если предпорядок (на S) к тому же антисимметричен, он называется частичным порядком (partial order) на S. Частичный порядок называется линейным порядком (total order) на S, если для любых s,tS выполняется либо st, либо ts.

Определение 3   Пусть — частичный порядок на S, а s и t — элементы S. Элемент jS называется объединением (join) (или точной верхней границей, least upper bound) s и t, если:
  1. sj и tj, а также
  2. для всякого kS, если sk и tk, то jk.

Аналогично, элемент mS называется пересечением (meet) (или точной нижней границей, greatest lower bound) s и t, если:

  1. ms и mt, а также
  2. для всякого nS, если ns и nt, то nm.
Определение 4   Рефлексивное, транзитивное и симметричное отношение на множестве S называется отношением эквивалентности (equivalence relation) на S.
Определение 5   Пусть R — бинарное отношение на множестве S. Рефлексивное замыкание (reflexive closure) R — это наименьшее рефлексивное отношение R, содержащее в себе R. (<<Наименьшее>> здесь означает, что, если имеется какое-то другое рефлексивное отношение R, включающее все пары из R, то R′ ⊆ R.) Аналогично, транзитивное замыкание (transitive closure) R — это наименьшее транзитивное отношение R, содержащее R. Транзитивное замыкание R часто обозначается R+. Рефлексивно-транзитивное замыкание (reflexive and transitive closure) R — это наименьшее рефлексивное и транзитивное отношение, содержащее R. Оно часто обозначается R*.
Упражнение 6   [★★ ↛] Дано отношение R на множестве S. Определим отношение R так:
R′ = R ⋃ { (s,s) ∣ s ∈ S }
То есть, R содержит все пары из R плюс все пары вида (s,s). Покажите, что R является рефлексивным замыканием R.
Упражнение 7   [★★ ↛] Вот более конструктивный способ определения транзитивного замыкания отношения R. Сначала определим следующую последовательность множеств пар:
    R0=R 
    Ri+1=Ri ⋃ { (s,u) ∣ для некоторого t, (s,t) ∈ Ri и (t,u) ∈ Ri
  
То есть, на каждом шаге i+1 мы добавляем к Ri все пары, которые можно получить <<за один шаг транзитивности>> из пар, входящих в Ri. Наконец, определим отношение R+ как объединение всех Ri:
R+ = 
 
i
 Ri
Покажите, что R+ на самом деле является транзитивным замыканием R — т. е., что оно удовлетворяет условиям, заданным в определении 5.
Упражнение 8   [★★ ↛] Пусть R — бинарное отношение на множестве S, и это отношение сохраняет предикат P. Докажите, что R* также сохраняет P.
Определение 9   Пусть у нас есть предпорядок на множестве S. Убывающая цепочка (decreasing chain) на есть последовательность s1, s2, s3, … элементов S, такая, что каждый элемент последовательности строго меньше предшествующего: si+1 < si для всякого i. (Цепочки могут быть конечными или бесконечными, однако для нас представляют больший интерес бесконечные, как видно из следующего определения.)
Определение 10   Пусть у нас есть множество S с предпорядком . Предпорядок называется полным (well founded), если он не содержит бесконечных убывающих цепочек. Например, обычный порядок на натуральных числах, 0 < 1 < 2 < 3 < …, является полным, а тот же самый порядок на целых числах, … < −3 < −2 < −1 < 0 < 1 < 2 < 3 < … — нет. Иногда мы не упоминаем явно и просто называем S вполне упорядоченным множеством (well-founded set).

2.3  Последовательности

Определение 1   Последовательность (sequence) записывается путем перечисления элементов через запятую. Запятая используется как для добавления элемента в начало (аналогично операции cons в языке Lisp) или конец последовательности, так и для склеивания двух последовательностей (аналогично append в Lisp). Например, если символ a обозначает последовательность 3,2,1, а символ b обозначает последовательность 5,6, то 0,a — это последовательность 0,3,2,1, запись a,0 обозначает последовательность 3,2,1,0, а запись b,a обозначает 5,6,3,2,1. (Использование запятой для двух разных операций — аналогов cons и append, — не создает путаницы, если нам не нужно вести речь о последовательностях последовательностей.) Последовательность чисел от 1 до n обозначается 1..n (через две точки). Запись |a| означает длину последовательности a. Пустая последовательность обозначается знаком или пробелом. Одна последовательность называется перестановкой (permutation) другой последовательности, если они содержат в точности одни и те же элементы, которые могут быть расположены в них в разном порядке.

2.4  Индукция

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

Аксиома 1   [Принцип обыкновенной индукции на натуральных числах] Пусть P — предикат, заданный на множестве натуральных чисел. Тогда
Если P(0)
и для любого
i, из P(i) следует P(i+1),
то
P(n) выполняется для всех n.

41ex

Аксиома 2   [Принцип полной индукции на натуральных числах] Пусть P — предикат на множестве натуральных чисел. Тогда
Если для каждого натурального числа n,
предполагая, что P(i) для всех i<n,
мы можем показать, что
P(n),
то P(n) выполняется для всех n.
Определение 3   Лексикографический (или <<словарный>>) порядок (lexicographic order) на парах натуральных чисел определяется следующим образом: (m,n) ≤ (m′,n′) тогда и только тогда, когда либо m < m, либо m=m и nn.
Аксиома 4   [Принцип лексикографической индукции] Пусть P — предикат на множестве пар натуральных чисел. Тогда
Если для каждой пары натуральных чисел (m,n),
предполагая, что P(m′,n′) для всех (m′,n′) < (m,n),
мы можем показать, что
P(m,n),
то P(m,n) выполняется для всех m,n.

Принцип лексикографической индукции служит основой для доказательств с вложенной индукцией (inner induction), когда какой-либо пункт индуктивного доказательства использует индукцию <<внутри себя>>. Этот принцип можно распространить на индукцию по тройкам, четверкам и т. д. (Индукция по парам нужна достаточно часто; индукция по тройкам иногда может быть полезной; индукция по четверкам и далее встречается редко.)

В теореме 4 вводится еще один вариант доказательств по индукции, называемый структурной индукцией (structural induction). Он особенно полезен для доказательства утверждений о древовидных структурах вроде термов или деревьях вывода типов. Математические основания индуктивных рассуждений будут подробнее рассмотрены в главе 21. Мы увидим, что все упомянутые принципы индукции являются частными проявлениями единой, более общей идеи.

2.5  Справочная литература

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

Доказательство есть воспроизводимый опыт в деле убеждения.Джим Хорнинг

Part I
Бестиповые системы

Chapter 3  Бестиповые арифметические выражения

1

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

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

3.1  Введение

В языке этой главы имеется лишь несколько синтаксических конструкций: булевские константы true (истина) и false (ложь), условные выражения, числовая константа 0, арифметические операторы succ (следующее число) и pred (предыдущее число) и операция проверки iszero, которая возвращает значение true, будучи применена к 0, и значение false, когда применяется к любому другому числу. Эти конструкции можно компактно описать следующей грамматикой.

t::= термы:
  trueконстанта <<истина>>
  falseконстанта <<ложь>>
  if t then t else tусловное выражение
  0константа <<ноль>>
  succ tследующее число
  pred tпредыдущее число
  iszero tпроверка на ноль


Формат описания этой грамматики (и других грамматик во всем тексте этой книги) близок к стандартной форме Бэкуса-Наура (см. Aho, Sethi, and Ullman, 1986). В первой строке (t ::=) определяется набор термов (terms), а также объявляется, что для обозначения термов мы будем употреблять букву t. Остальные строки описывают синтаксические формы, допустимыe для термов. Всюду, где встречается символ t, можно подставить любой терм. В правой колонке курсивом набраны комментарии.

Символ t в правой части описания грамматики называется метапеременной (metavariable). Это переменная в том смысле, что t служит в качестве заместителя какого-то конкретного терма, но это <<мета>>-переменная, поскольку она не является переменной объектного языка (object language) — то есть, самого языка программирования, синтаксис которого мы описываем, — а метаязыка (metalanguage), знаковой системы, используемой для описания. (На самом деле, в этом объектном языке даже нет переменных; мы их введем лишь в главе 5.) Приставка мета- происходит из метаматематики (meta-mathematics) — отрасли логики, которая изучает математические свойства систем, предназначенных для математических и логических рассуждений (в частности, языков программирования). Оттуда же происходит термин метатеория (metatheory); он означает совокупность истинных утверждений, которые мы можем сделать о какой-либо логической системе (или о языке программирования), а в расширенном смысле — исследование таких утверждений. То есть, такое выражение как <<метатеория подтипов>> в этой книге может пониматься как <<формальное исследование свойств систем с подтипами>>.

В тексте этой книги мы будем использовать метапеременную t, а также соседние буквы, скажем, s, u и r, и варианты вроде t1 или s, для обозначения термов того объектного языка, которым мы занимаемся в данный момент; далее будут введены и другие буквы для обозначения выражений других синтаксических категорий. Полный список соглашений по использованию метапеременных можно найти в приложении B.

Пока что слова терм и выражение обозначают одно и то же. Начиная с главы 8, в которой мы станем изучать исчисления, обладающие более богатым набором синтаксических категорий (таких, как типы), словом выражение мы будем называть любые синтаксические объекты (в том числе выражения-термы, выражения-типы, выражения-виды и т. п.), а терм будет употребляться в более узком смысле — обозначая выражения, которые представляют собой вычисления (т. е. такие выражения, которые можно подставить вместо метапеременной t).

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

if false then 0 else 1; |> 1 iszero (pred (succ 0)); |> true

Символом ▷ в тексте книги обозначаются результаты вычисления примеров. (Для краткости результаты опускаются, когда они очевидны или не играют никакой роли.) При верстке результаты автоматически вставляются реализацией той формальной системы, которая рассматривается в данный момент (в этой главе — arith); напечатанные результаты представляют собой реальный вывод приложения.1

Составные аргументы succ, pred и iszero в примерах приведены, для удобства чтения, в скобках.1 Скобки не упоминаются в грамматике термов; она определяет только абстрактный синтаксис (abstract syntax). Разумеется, присутствие скобок или их отсутствие играет весьма малую роль в чрезвычайно простом языке, с которым мы сейчас имеем дело: обычно скобки служат для разрешения неоднозначностей в грамматике, но в нашей грамматике неоднозначностей нет — любая последовательность символов может быть интерпретирована как терм максимум одним способом. Мы вернемся к обсуждению скобок и абстрактного синтаксиса в главе 5 (с. ??).

Результатом вычислений служат термы самого простого вида: это всегда либо булевские константы, либо числа (последовательные вызовы succ ноль или более раз с аргументом 0). Такие термы называются значениями (values), и они будут играть особую роль при формализации порядка вычисления термов.

Заметим, что синтаксис термов позволяет образовывать термы сомнительного вида, вроде succ true или if 0 then 0 else 0. Мы поговорим о таких термах позднее — в сущности, именно их наличие делает этот крошечный язык интересным для наших целей, поскольку они являются примерами именно тех бессмысленных программ, которых мы хотим избежать при помощи системы типов.

3.2  Синтаксис

Есть несколько эквивалентных способов определить синтаксис нашего языка. Один из них мы уже видели — это грамматика, приведенная на с. ??. Грамматика эта, в сущности, всего лишь сокращенная форма записи следующего индуктивного определения:

Определение 1   [Термы, индуктивно]: Множество термов — это наименьшее множество T такое, что:
  1. {true, false, 0} ⊆ T;
  2. если t1T, то { succ t1, pred t1, iszero t1} ⊆ T;
  3. если t1T, t2T, t3T, то if t1 then t2 else t3T.

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

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

Еще один способ представления того же индуктивного определения термов использует двумерную запись в виде правил вывода (inference rules), часто используемую в форме <<естественного вывода>> для представления логических систем:

Определение 2   [Термы, через правила вывода]: Множество термов определяется следующими правилами:
    true ∈ T 
    false ∈ T 
    0 ∈ T 
    
t1 ∈ T
succ t1 ∈ T
 
    
t1 ∈ T
pred t1 ∈ T
 
    
t1 ∈ T
iszero t1 ∈ T
 
    
t1 ∈ T           t2 ∈ T           t3 ∈ T           
if t1 then t2 else t3 ∈ T
 

Первые три правила повторяют первый пункт определения 1; остальные четыре соответствуют пунктам (2) и (3). Каждое правило вывода читается так: <<Если мы установили истинность предпосылок, указанных над чертой, то мы можем прийти к заключению под чертой>>. Утверждение о том, что T должно являться наименьшим множеством, удовлетворяющим этим правилам, зачастую не приводится явно (как в данном случае).

Стоит упомянуть два терминологических момента. Во-первых, правила без предпосылок (как первые три в нашем определении) часто называют аксиомами (axioms). В этой книге термин правило вывода используется как для <<собственно правил вывода>>, для которых имеется одна или несколько предпосылок, так и для аксиом. Аксиомы обычно записываются без черты, поскольку над ней нечего писать. Во-вторых, если выражаться абсолютно точно, наши <<правила вывода>> на самом деле представляют собой схемы правил (rule schemas), поскольку предпосылки и заключения в них могут содержать метапеременные. С формальной точки зрения, каждая схема представляет бесконечное множество конкретных правил (concrete rules), получаемых путем замены каждой метапеременной различными выражениями, которые принадлежат соответствующей синтаксической категории — т. е., в данном случае, вместо каждого t подставляются все возможные термы.

Наконец, то же самое множество термов можно определить ещё одним способом, в более <<конкретном>> стиле, явно указав процедуру порождения (generation) элементов T.

Определение 3   [Термы, конкретным образом]: Для каждого натурального числа i определим множество Si:
    S0=∅ 
    Si+1=
    
 {truefalse0
       ⋃{succ t1pred t1, iszero t1 ∣ t1 ∈ Si
       ⋃{if t1 then t2 else t3 ∣ t1t2t3 ∈ Si}.
Наконец, пусть
S = 
 
i
 Si.

S0 пусто; S1 содержит только константы; S2 содержит константы и выражения, которые можно построить из констант путем применения одной из операций succ, pred, iszero или if; S3 содержит все эти выражения плюс те, которые можно построить за одно применение succ, pred, iszero или if к элементам S2; и так далее. S собирает вместе все эти выражения — т. е., все выражения, которые можно получить применением конечного числа арифметических и условных операторов, начиная с констант.

Упражнение 4   [★★]: Сколько элементов содержит S3?
Упражнение 5   [★★]: Покажите, что множества Si кумулятивны (cumulative) — то есть, для любого i выполняется SiSi+1.

Рассмотренные нами определения характеризуют одно и то же множество термов с разных точек зрения: определения 1 и 2 просто описывают множество как наименьшее, имеющее некоторые <<свойства замыкания>>; определение 3 показывает, как построить множество в виде предела последовательности.

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

Утверждение 6   T = S.
Доказательство: T определяется как наименьшее множество, удовлетворяющее некоторым условиям. Таким образом, достаточно показать, что (а) эти условия выполняются на S; и (б) любое множество, удовлетворяющее условиям, содержит S как подмножество (т. е, что Sнаименьшее множество, удовлетворяющее условиям).

В части (а) нам требуется проверить, что все три условия из определения 1 выполняются на S. Во-первых, поскольку S1 = {true, false, 0}, ясно, что константы содержатся в S. Во-вторых, если t1S, то, поскольку S = ∪i Si, должно иметься некоторое i, такое, что t1Si. Но тогда, по определению Si+1, мы имеем succ t1Si+1, а следовательно, succ t1S; аналогично, pred t1S и iszero t1S. В-третьих, аналогичное рассуждение показывает, что если t1S, t2S, и t3S, то if t1 then t2 else t3S.

Для части (б) предположим что некоторое множество S удовлетворяет всем трем условиям из определения 1. При помощи полной индукции по i мы докажем, что все SiS, откуда очевидно следует, что SS.

Допустим, что SjS для всех j < i; мы должны показать, что SiS. Поскольку определение Si состоит из двух пунктов (для i=0 и i > 0), требуется рассмотреть оба случая. Если i=0, то Si = ∅; очевидно, что ∅ ⊆ S. В противном случае, i = j+1 для некоторого j. Пусть t — некоторый элемент Sj+1. Поскольку Sj+1 определяется как объединение трех меньших множеств, требуется рассмотреть три возможных случая. (1) Если t — константа, то tS по условию 1. (2) Если t имеет вид succ t1, pred t1 или iszero t1, для некоторого t1Sj, то, по предположению индукции, t1S, и тогда, по условию 2, tS. (3) Если t имеет вид if t1 then t2 else t3 для некоторых t1, t2, t3Sj, то, опять же, согласно предположению индукции, t1, t2 и t3 содержатся в S, и, по условию 3, в нем содержится также и t.

Таким образом, мы показали, что все SiS. Поскольку S определено как объединение всех Si, мы имеем SS, что и завершает доказательство.

Стоит заметить, что доказательство использует полную индукцию по всем натуральным числам, а не более привычный образец <<базовый случай / шаг индукции>>. Для каждого i мы предполагаем, что нужное условие выполняется для всех чисел строго меньше i, и доказываем, что и для i оно тоже выполняется. В сущности, каждый шаг здесь является шагом индукции; единственное, что выделяет случай i=0 — это то, что множество чисел, меньших i, для которых мы можем использовать индуктивное предположение, оказывается пустым. То же соображение будет относиться к большинству индуктивных доказательств на всем протяжении этой книги — особенно к доказательствам по <<структурной индукции>>.

3.3  Индукция на термах

Явная характеризация множества термов T в Утверждении 6 позволяет нам сформулировать важный принцип изучения его элементов. Если tT, то должно быть истинно одно из следующих утверждений: (1) t является константой, либо (2) t имеет вид succ t1, pred t1 или iszero t1, причем t1 меньше, чем t, либо, наконец (3) t имеет вид if t1 then t2 else t3, причем t1, t2 и t3 меньше, чем t. Это наблюдение можно использовать двумя способами: во-первых, мы можем строить индуктивные определения (inductive definitions) функций, действующих на множестве термов, а во-вторых, мы можем давать индуктивные доказательства (inductive proofs) свойств термов. Вот, например, индуктивное определение функции, которая ставит в соответствие каждому терму множество констант, в нем использованных.

Определение 1   Множество констант, встречающихся в терме t (записывается Consts(t)), определяется так:
    Consts(true)={true
    Consts(false)={false
    Consts(0)={0
    Consts(succ t1)=Consts(t1
    Consts(pred t1)=Consts(t1
    Consts(iszero t1)=Consts(t1
    Consts(if t1 then t2 else t3)=      Consts(t1) ⋃ Consts(t2) ⋃ Consts (t3)
  

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

Определение 2   Размер (size) термa t (записывается size(t)) определяется так:
    size(true)=
    size(false)=
    size(0)=
    size(succ t1)=size(t1) + 1 
    size(pred t1)=size(t1) + 1 
    size(iszero t1)=size(t1) + 1 
    size(if t1 then t2 else t3)=size(t1) + size(t2) + size(t3) +1 
  
Таким образом, размер t — это число вершин в его абстрактном синтаксическом дереве. Аналогично, глубина (depth) терма t (записывается depth(t)) определяется так:
    depth(true)=
    depth(false)=
    depth(0)=
    depth(succ t1)=depth(t1) + 1 
    depth(pred t1)=depth(t1) + 1 
    depth(iszero t1)=depth(t1) + 1 
    depth(if t1 then t2 else t3)=max(depth(t1), depth(t2), depth(t3)) + 1 
  
Другое, эквивалентное, определение, таково: depth(t) — это наименьшее i, такое что tSi согласно определению 1.

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

Лемма 3   Число различных констант в терме t не больше его размера (т. е., |Consts(t)| ≤ size(t)).
Доказательство: индукция по глубине терма t. В предположении, что свойство выполняется для всех термов, меньших t, следует доказать его по отношению к самому t. Требуется рассмотреть три варианта:

Вариант: t — константа.
Свойство выполняется непосредственно:
|Consts(t)| = |{t}| = 1 = size(t).

Вариант: t = succ t1, pred t1 или iszero t1
Согласно индуктивному предположению,
|Consts(t1)| ≤ size(t1). Рассуждаем так: |Consts(t)| = |Consts(t1)| ≤ size(t1) < size(t).

Вариант: t = if t1 then t2 else t3
Согласно индуктивному предположению,
|Consts(t1)| ≤ size(t1), |Consts(t2)| ≤ size(t2), |Consts(t3)| ≤ size(t3). Рассуждаем так:

    |Consts(t)|=      |Consts(t1)  ⋃ Consts(t2) ⋃ Consts(t3) | 
       |Consts(t1)| + |Consts(t2)| + |Consts(t3)| 
       size(t1) + size(t2) + size(t3
 <size (t)

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

Теорема 4   [Принципы индукции по термам] Пусть P — предикат, определенный на множестве термов.
Индукция по глубине:
Если для каждого терма s,
предполагая, что P(r) для всех r, таких, что depth(r) < depth(s),
можно доказать, что
P(s),
то P(s) выполняется для всех s.
Индукция по размеру:
Если для каждого терма s,
предполагая, что P(r) для всех r, таких, что size(r) < size(s),
можно доказать, что
P(s),
то P(s) выполняется для всех s.
Структурная индукция:
Если для каждого терма s,
предполагая, что P(r) для всех непосредственных подтермов s, можно доказать, что P(s),
то P(s) выполняется для всех s.
Доказательство: Упражнение (★★).

Индукция по глубине или размеру термов аналогична полной индукции на натуральных числах (2). Обыкновенная структурная индукция соответствует принципу обыкновенной индукции на натуральных числах (1), в которой шаг индукции требует, чтобы P(n+1) выводилось исключительно из предположения P(n).

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

Большинство доказательств по индукции на термах имеет одну и ту же структуру. На каждом шаге индукции дается терм t, для которого нужно продемонстрировать некоторое свойство P, в предположении, что P выполняется на всех его подтермах (или на всех термах меньшего размера). Это делается путем рассмотрения всех возможных форм, которые может иметь t (true, false, 0, условное выражение и т. п.), доказывая в каждом случае, что P должно выполняться на всех термах этого вида. Поскольку от одного доказательства к другому меняются лишь детали рассуждений в конкретных случаях, принято опускать повторяющиеся части доказательств, и записывать их таким образом:

Доказательство: Индукция по t:

Вариант: t = true
…доказываем, что P(true)…

Вариант: t = false
…доказываем, что P(false)…

Вариант: t = if t1 then t2 else t3
…доказываем, что P(if t1 then t2 else t3), используя P(t1), P(t2) и P(t3)…

(И так же для остальных синтаксических форм.)

Во многих индуктивных доказательствах (включая 3) даже такой уровень подробности излишен: в базовых случаях (для термов t, не имеющих подтермов) P(t) очевидно, а в индуктивных случаях P(t) выводится путем применения индуктивного предположения к подтермам t и сочетания результатов некоторым очевидным способом. Читателю оказывается проще воссоздать доказательство на ходу (перебирая правила грамматики и держа в уме индуктивное предположение), чем прочитать явно записанные шаги. В таких ситуациях в качестве доказательства вполне приемлема фраза <<индукция по t>>.

3.4  Семантические стили

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

  1. Операционная семантика (operational semantics) специфицирует поведение языка программирования, определяя для него простую абстрактную машину (abstract machine). Машина эта <<абстрактна>> в том смысле, что в качестве машинного кода она использует термы языка, а не набор команд какого-то низкоуровневого микропроцессора. Для простых языков состояние (state) машины — это просто терм, а поведение ее определяется функцией перехода (transition function), которая для каждого состояния либо указывает следующее состояние, произведя шаг упрощения старого терма, либо объявляет машину остановившейся. Смыслом (meaning) терма t объявляется конечное состояние, которого машина достигает, будучи запущена с начальным состоянием t.2

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

  2. Денотационная семантика (denotational semantics) рассматривает смысл с более абстрактной точки зрения: смыслом терма считается не последовательность машинных состояний, а некоторый математический объект, например, число или функция. Построение денотационной семантики для языка состоит в нахождении некоторого набора семантических доменов (semantic domains), а также определении функции интерпретации (interpretation function), которая ставит элементы этих доменов в соответствие термам. Поиск подходящих семантических доменов для моделирования различных языковых конструкций привел к возникновению сложной и изящной области исследований, известной как теория доменов (domain theory).

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

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

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

В 60-е и 70-е годы считалось, что операционная семантика уступает двум другим стилям; что она полезна для быстрого и грубого определения характеристик языка, но в целом менее изящна и слаба с математической точки зрения. Однако в 80-е годы более абстрактные методы стали сталкиваться со все более неприятными техническими сложностями,3 и простота и гибкость операционной семантики стали по сравнению с ними выглядеть все более привлекательными — особенно в свете новых достижений, начиная со <<Структурной операционной семантики>> (Structural Operational Semantics) Плоткина (1981), <<Естественной семантики>> (Natural Semantics) Кана (1987) и работ Милнера по <<Исчислению взаимодействующих систем>> (Calculus of Communicating Systems, CCS; (???)). В этих работах были введены в обращение новые изящные формализмы, и было показано, как перенести на операционную почву многие мощные методы, изначально разработанные в рамках денотационной семантики. Операционная семантика стала быстро развиваться и часто выбирается в качестве средства для определения языков программирования и изучения их свойств. Именно она используется в этой книге.

3.5  Вычисление


B (бестиповое)



Синтаксис
t::= термы:
true константа <<истина>>
false константа <<ложь>>
if t then t else t условное
выражение
 
v::= значения:
true константа <<истина>>
false константа <<ложь>>
Вычислениеtttt
     
        if true then t2 else t3t2              (E-IfTrue)
if false then t2 else t3t3              (E-IfFalse)
t1 → t1
if t1 then t2 else t3                             → if t1 then t2 else t3
             (E-If)



Figure 3.1: Булевские выражения (B)


Забудем на время о натуральных числах и рассмотрим операционную семантику только булевских выражений. Определение приведено на рис. 3.1. Рассмотрим его в деталях.

В левом столбце рис. 3.1 приведена грамматика, в которой определяются два набора выражений. Во-первых, повторяется (для удобства) синтаксис термов. Во-вторых, определяется подмножество термов — множество значений (values), которые являются возможными результатами вычисления. В нашем случае значениями являются только константы true и false. На протяжении всей книги для значений используется метапеременная v.

В правом столбце определяется отношение вычисления (evaluation relation)4 на термах. Оно записывается в виде tt′ и читается <<t за один шаг вычисляется в t>>. Интуитивно ясно, что если в какой-то момент абстрактная машина находится в состоянии t, то она может произвести шаг вычисления и перейти в состояние t. Это отношение определяется тремя правилами вывода (или, если угодно, двумя аксиомами и одним правилом: первые два правила не имеют предпосылок).

Первое правило, E-IfTrue, говорит, что при вычислении условного выражения, в котором условием служит константа true, машина может выкинуть это условное выражение, оставив истинную ветвь t2 в качестве состояния (т. е., следующего подлежащего вычислению терма). Аналогично, правило E-IfFalse говорит, что условное выражение с константой false в качестве условия переходит за один шаг в свою ложную ветвь t3. Префикс E- в названиях этих правил напоминает, что они являются частью отношения вычисления; у правил, определяющих другие отношения, будут в названиях другие префиксы.

Третье правило вычисления, E-If, более интересно. Оно говорит, что если условие t1 переходит за один шаг в t1, то условное выражение if t1 then t2 else t3 целиком переходит за шаг в if t1 then t2 else t3. Рассуждая в терминах абстрактных машин, машина, находящаяся в состоянии if t1 then t2 else t3, может за один шаг перейти в состояние if t1 then t2 else t3, если другая машина из состояния t1, может за шаг перейти в t1.

То, о чем эти правила не говорят, не менее важно, чем то, что они говорят. Константы true и false ни во что не вычисляются, поскольку они не присутствуют в левой части никакого из правил. Более того, не существует правила, которое бы позволило вычислять истинную или ложную ветвь if-выражения, пока не вычислено условие: например, терм

if true then (if false then false else false) else true

не переходит в if true then false else true. Единственная разрешенная возможность — вычислить сначала внешнее условное выражение, используя E-IfTrue. Такое взаимодействие между правилами определяет конкретную стратегию вычисления (evaluation strategy) для условных выражений, соответствующую привычному порядку вычислений в большинстве языков программирования: чтобы вычислить условное выражение, нужно вычислить его условие; если условие само является условным выражением, требуется вычислить его условие; и так далее. Правила E-IfTrue и E-IfFalse говорят нам, что нужно сделать, когда мы достигли конца этого процесса и обнаружили условное выражение, условие которого уже полностью вычислено. В некотором смысле, E-IfTrue и E-IfFalse проделывают работу по вычислению, в то время как E-If помогает определить, где эта работа должна выполняться. Иногда разный характер этих правил подчеркивают, называя E-IfTrue и E-IfFalse рабочими правилами (computation rules), в то время как E-If является правилом соответствия (congruence rule).

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

Определение 1   Экземпляр (instance) правила вывода получается при замене каждой метапеременной одним и тем же термом в заключении правила и во всех его предпосылках (если они есть).

Например,

if true then true else (if false then false else false) → true

является экземпляром правила E-IfTrue, в котором оба вхождения t2 заменяются на true, а t3 заменяется на if false then false else false.

Определение 2   Правило выполняется (is satisfied) на отношении, если для каждого экземпляра правила его заключение является элементом отношения, либо одна из его предпосылок не является таковой.
Определение 3   Одношаговое отношение вычисления (one-step evaluation relation)  есть наименьшее бинарное отношение на термах, на котором выполняются все три правила из рис. 3.1. Если пара (t, t) является элементом отношения вычисления, то мы говорим, что утверждение (или суждение) о вычислении tt′ выводимо (the evaluation statement (judgement) is derivable).

Значение слова <<наименьшее>> в этом определении таково: утверждение tt′ выводимо тогда и только тогда, когда оно обеспечено правилами: либо это экземпляр одной из аксиом E-IfTrue или E-IfFalse, либо это заключение экземпляра правила E-If с выводимой предпосылкой. Выводимость утверждения можно обосновать через дерево вывода (derivation tree), в котором листьями служат экземпляры правил E-IfTrue и E-IfFalse, а внутренними вершинами — экземпляры правила E-If. Например, если мы введем сокращения

s
def
=
 
if true then false else false 
t
def
=
 
if s then true else true
u
def
=
 
if false then true else true

(чтобы вывод помещался на странице), то выводимость утверждения

if t then false else false →  if u then false else false

можно обосновать при помощи дерева

 E-IfTrue
s → false 
 E-If
t → u 
 E-If
if t then false else false → if u then false else false 

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

Тот факт, что утверждение о вычислении tt′ выводимо тогда и только тогда, когда существует дерево вывода с tt′ в корне, часто оказывается полезным при исследовании свойств отношения вычисления. В частности, это немедленно подсказывает метод доказательства, называемый индукцией по выводам (induction on derivations). Доказательство следующей теоремы может служить иллюстрацией этой методики.

Теорема 4   [Детерминированность одношагового вычисления] Если tt и tt, то t = t.
Доказательство: Индукция по выводу tt. На каждом шаге индукции мы предполагаем теорему доказанной для всех меньших деревьев вывода, и анализируем правило вычисления, находящееся в корне дерева. (Заметим, что индукция здесь не идет по длине цепочки вычисления: мы смотрим только на один ее шаг. С тем же успехом можно было бы сказать, что индукция проводится по структуре t, поскольку структура <<вывода шага вычисления>> прямо повторяет структуру редуцируемого терма. Кроме того, мы могли бы точно так же провести индукцию по выводу tt.)

Если последний шаг, использованный в выводе tt, является экземпляром правила E-IfTrue, то ясно, что t имеет вид if t1 then t2 else t3, причем t1 = true. Но тогда понятно, что последний шаг в выводе tt не может быть E-IfFalse, поскольку равенства t1 = true и t1 = false не могут выполняться одновременно. Более того, последнее правило во втором дереве вывода не может быть и E-If, потому что предпосылка этого правила требует t1t1 для некоторого t1, однако, как мы уже заметили, true не может ни во что перейти. Таким образом, последним правилом во втором дереве вывода может быть только E-IfTrue, откуда немедленно следует, что t = t.

Точно так же, если последнее правило при выводе tt — экземпляр E-IfFalse, таково же должно быть и последнее правило при выводе tt, и результат, опять же, очевиден.

Наконец, если последним при выводе tt применяется правило E-If, то из его формы можно заключить, что t имеет вид if t1 then t2 else t3, причем для некоторого t1 выполняется t1t1. При помощи таких же рассуждений, что и выше, мы можем заключить, что последним правилом в выводе tt может быть только E-If, откуда ясно, что t имеет вид if t1 then t2 else t3 (что нам уже и так известно), и что для некоторого t1 выполняется t1t1. Но тогда мы можем применить предположение индукции (поскольку деревья вывода t1t1 и t1t1 являются поддеревьями выводов tt и tt соответственно) и заключить t1 = t1. Отсюда видно, что t = if t1 then t2 else t3 = if t1 then t2 else t3 = t, как нам и требуется.

Упражнение 5   [★] Сформулируйте в стиле теоремы 4 принцип индукции, используемый в приведенном выше доказательстве.

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

Определение 6   Терм t находится в нормальной форме (normal form), если к нему не применимо никакое правило вычисления — т. е., если не существует такого t, что tt. (Для краткости мы иногда будем говорить <<t является нормальной формой>> вместо <<t является термом в нормальной форме>>.)

Мы уже заметили, что в нашей текущей системе true и false — нормальные формы (поскольку во всех правилах вычисления левые части являются условными выражениями, очевидно, невозможно построить экземпляр правила, где в качестве левой части выступали бы true или false). Можно обобщить это наблюдение в виде свойства всех значений:

Теорема 7   Всякое значение находится в нормальной форме.

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

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

Теорема 8   Если t находится в нормальной форме, то t — значение.
Доказательство: Предположим, что t не является значением. При помощи структурной индукции на t легко показать, что t — не нормальная форма.

Если t — не значение, оно должно иметь вид if t1 then t2 else t3 для некоторых t1, t2, t3. Рассмотрим возможные формы t1.

Если t1 = true, то, очевидно, t не является нормальной формой, поскольку он подпадает под левую часть правила E-IfTrue. Аналогично в случае t1 = false.

Если t1 не равно ни true, ни false, то оно не является значением. В таком случае применимо предположение индукции, утверждающее, что t1 — не нормальная форма, а именно, что существует некий терм t1, такой, что t1t1. Но тогда мы можем применить правило E-If, получая tif t1 then t2 else t3. Таким образом, t не является нормальной формой.

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

Определение 9   Отношение многошагового вычисления (multi-step evaluation relation) * — это рефлексивно-транзитивное замыкание отношения (одношагового) вычисления. То есть, это наименьшее отношение, такое, что (1) если tt, то t* t, (2) для всех t выполняется t* t, и (3) если t* t и t* t, то t* t.
Упражнение 10   [★] Переформулируйте определение 9 в виде набора правил вывода.

Явный способ записи многошагового вычисления облегчает формулировку, например, таких утверждений:

Теорема 11   [Единственность нормальных форм] Если t* u и t* u, причем и u, и u — нормальные формы, то u = u’.
Доказательство: Теорема следует из детерминированности одношагового вычисления (4).

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

Большинство доказательств гарантии завершения в информатике имеют одну и ту же базовую структуру.5 Сначала выберем некоторое вполне упорядоченное множество S и функцию f, переводящую <<машинные состояния>> (в нашем случае — термы) в элементы S. Затем покажем, что всякий раз, когда машинное состояние t может перейти в другое состояние t, выполняется неравенство f(t) < f(t). Теперь можно заметить, что бесконечная цепочка шагов вычисления, которая начинается с t, может быть переведена в бесконечную убывающую последовательность элементов S. Поскольку S вполне упорядочено, такая бесконечная убывающая цепочка существовать не может, а, соответственно, не может быть и бесконечной последовательности шагов вычисления. Функцию f часто называют мерой завершения (termination measure) отношения вычисления.

Теорема 12   [Завершение вычислений] Для каждого терма t существует нормальная форма t такая, что t* t.
Доказательство: Достаточно заметить, что каждый шаг вычисления уменьшает размер терма, и что размер может служить мерой завершения, поскольку обычный порядок на натуральных числах является полным.
Упражнение 13   [Рекомендуется,★★]
  1. Предположим, что мы добавили к правилам на рис. 3.1 еще одно:
    if true then t2 else t3 → t3 (E-Funny1)
    Какие из теорем 4, 7, 8, 11 и 12 остаются истинными?
  2. Теперь допустим, что мы добавляем такое правило:
    t2 → t2 (E-Funny2)
    if t1 then t2 else t3 → if t1 then t2 else t3 
    Какие теоремы сохраняются в этом случае? Требуется ли модифицировать какие-либо доказательства?

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


B (бестиповое) Расширяет B (3.1)



Новые синтаксисические формы
t::= …термы:
0 константа ноль
succ t следующее число
pred t предыдущее число
iszero t проверка на ноль
v::= …значения:
nv числовое значение
nv::= …числовые значения:
0 нулевое значение
succ nv значение-последователь

Новые правила вычисленияtttt

        
t1 → t1
succ t1 → succ t1
        pred 00
        pred (succ nv1)nv1
        
t1 → t1
pred t1 → pred t1
        iszero 0true
        iszero (succ nv1)false
        
t1 → t1
iszero t1 → iszero t1



Figure 3.2: Арифметические выражения (NB)


Как и раньше, определение термов просто повторяет синтаксис, который мы уже видели в §3.1. Определение значений несколько интереснее, поскольку приходится вводить новую категорию числовых значений (syntactic values). Интуитивное представление таково: окончательным результатом вычисления арифметического выражения может быть число, причем под числом понимается либо 0, либо значение-последователь числа (но не последователь произвольного значения: succ(true) должно считаться ошибкой, а не значением).

Правила вычисления в правом столбце рис. 3.2 построены по той же схеме, что и на рис. 3.1. Четыре рабочих правила (E-PredZero, E-PredSucc, E-IszeroZero и E-IszeroSucc) показывают, как операторы pred и iszero действуют на числовые значения, а три правила соответствия (E-Succ, E-Pred и E-IsZero) управляют вычислением <<первого>> подтерма составного терма.

Строго говоря, сейчас следовало бы повторить определение 3 (<<Одношаговое отношение вычисления → есть наименьшее бинарное отношение на термах, на котором выполняются все три правила из рис. 3.1 и 3.2…>>). Чтобы не тратить место на такого рода служебные определения, обычно считают, что правила вывода сами по себе представляют определение, а формулировка <<наименьшее отношение, содержащее все экземпляры…>> подразумевается.

В этих правилах важную роль играет синтаксическая категория числовых значений (nv). Например, в E-PredSucc требование, чтобы левая часть имела вид pred (succ nv1), а не просто pred (succ t1), приводит к тому, что это правило нельзя использовать для перевода pred (succ (pred 0)) в pred 0, поскольку для такого шага потребовалось бы заменить метапеременную nv1 на терм pred 0, который не является числовым значением. В результате единственный разрешенный шаг при вычислении терма pred (succ (pred 0)) имеет следующее дерево вывода:

 E-PredZero
pred 0 → 0 
 E-Succ
succ (pred 0) → succ 0 
 E-Pred
pred (succ (pred 0)) → pred (succ 0) 
Упражнение 14   [★★] Покажите, что теорема 4 верна и для отношения вычисления на арифметических выражениях: если tt и tt, то t = t.

Формализация операционной семантики языка требует определения поведения всех термов, включая такие термы нашего языка, как pred 0 и succ false. По правилам рис. 3.2, предшествующим числом 0 считается 0. С другой стороны, последователь false не вычисляется никак (другими словами, этот терм является нормальной формой). Такие термы мы называем тупиковыми.

Определение 15   Терм называется тупиковым (stuck), если он находится в нормальной форме, но не является значением.

Понятие <<тупикового терма>> представляет в нашей простой машине ошибки времени выполнения (run-time errors). С интуитивной точки зрения, оно описывает ситуации, когда операционная семантика не знает, что делать дальше, поскольку программа оказалась в <<бессмысленном>> состоянии. В более конкретной реализации языка такие состояния могут соответствовать различного рода ошибкам: обращениям по несуществующему адресу, попыткам выполнить запрещенную машинную команду и т. п. Мы объединяем все эти виды неправильного поведения в единую категорию <<тупикового состояния>>.

Упражнение 16   [Рекомендуется,★★★]
Другой способ формализации бессмысленных состояний абстрактной машины состоит в том, чтобы ввести новый терм
wrong (<<неправильное значение>>) и дополнить операционную семантику правилами, которые бы явным образом порождали wrong во всех ситуациях, которые в нынешней семантике приводят к тупику. А именно, мы вводим две новые синтаксические категории
    badnat::=нечисловые нормальные формы
 wrongошибка времени выполнения 
 trueконстанта <<истина>> 
 falseконстанта <<ложь>> 
    badbool::=небулевские нормальные формы 
 wrongошибка времени выполнения 
 nvчисловое значение 
  
и дополняем отношение вычисления следующими правилами:
     
    if badbool then t1 else t2 →     wrong            (E-If-Wrong)
succ badnat →     wrong            (E-Succ-Wrong)
pred badnat →     wrong            (E-Pred-Wrong)
iszero badnat →     wrong             (E-IsZero-Wrong)

Покажите, что эти два подхода к формализации ошибок времени выполнения согласуются между собой. Для этого нужно (1) найти способ точного выражения интуитивной идеи о том, что <<два подхода согласуются>>, и (2) доказать ее. Как это часто бывает при изучении языков программирования, сложнее всего сформулировать точное утверждение, подлежащее доказательству — само доказательство после этого построить нетрудно.

Упражнение 17   [Рекомендуется,★★★]
Обычно используется два различных стиля операционной семантики. В этой книге используется так называемая семантика
с малым шагом (small-step): в определении отношения вычисления показано, как отдельные шаги вычисления используются для переписывания частей терма, фрагмент за фрагментом, пока в конце концов не получится значение. На базе этого отношения мы определяем многошаговое отношение вычисления, которое позволяет нам говорить о том, как термы (за много шагов) вычисляются и дают значения. Другой стиль — семантика с большим шагом (big-step) (или, иногда, естественная семантика, natural semantics), прямо определяет понятие <<терм такой-то при вычислении дает такое-то значение>>, которое записывается как tv. Правила с большим шагом для нашего языка с булевскими и арифметическими выражениями выглядят так:
    v ⇓ v
    
t1 ⇓ true           t2 ⇓ v2
if t1 then t2 else t3 ⇓ v2
 
    
t1 ⇓ false            t3 ⇓ v3
if t1 then t2 else t3 ⇓ v3
 
    
t1 ⇓ nv1
succ t1 ⇓  succ nv1
 
    
t1 ⇓ 0
pred t1 ⇓  0
 
    
t1 ⇓ succ nv1
pred t1 ⇓  nv1
 
    
t1 ⇓ 0
iszero t1 ⇓  true
 
    
t1 ⇓ succ nv1
iszero t1 ⇓  false
 

Покажите, что семантика с малым шагом и с большим шагом для нашего языка дают один и тот же результат, т. е., t* v тогда и только тогда, когда tv.

Упражнение 18   [★★↛]
Предположим, что нам захотелось поменять стратегию вычисления для нашего языка, так, чтобы ветви
then и else в условном выражении вычислялись (в указанном порядке) до того, как вычислится само условие. Покажите, как нужно изменить правила вычисления, чтобы добиться такого поведения.

3.6  Дополнительные замечания

Понятия абстрактного и конкретного синтаксиса, синтаксического анализа и т. п. объясняются во многих учебниках по компиляторам. Индуктивные определения, системы правил вывода и доказательства по индукции рассмотрены более подробно в книгах Винскеля (1993) и Хеннесси (Hennessy, 1990).

Стиль операционной семантики, которым мы здесь пользуемся, восходит к техническому отчету Плоткина (1981). Стиль с большим шагом (17) был разработан Каном (1987). Более подробно они описаны у Астезиано (Astesiano, 1991) и у Хеннесси (Hennessy, 1990).

Структурную индукцию ввел в информатику Берсталл (Burstall, 1969).

В.: Зачем доказывать свойства языков программирования? Если в определениях нет ошибок, доказательства почти всегда получаются очень скучными.
О.: В определениях почти всегда есть ошибки. Автор неизвестен


1
В этой главе изучается бестиповое исчисление логических значений и чисел (рис. 3.2 на с. ??). Соответствующая реализация на OCaml хранится в веб-репозитории под именем arith и описывается в главе 4. Инструкции по скачиванию и сборке программы проверки можно найти по адресу http://www.cis.upenn.edu/~bcpierce/tapl.
1
При верстке перевода это правило не соблюдалось. — прим. перев.
1
На самом деле интерпретатор, которым обрабатывались примеры из этой главы (на сайте книги он называется arith) требует скобок вокруг составных аргументов succ, pred и iszero, даже несмотря на то, что их можно однозначно разобрать и без скобок. Это сделано для совместимости с последующими исчислениями, где подобный синтаксис используется при применении функций к аргументам.
2
Строго говоря, то, что мы здесь описываем, — это так называемая операционная семантика с малым шагом (small-step operational semantics), известная также как структурная операционная семантика (structural operational semantics) (Plotkin, 1981). В упражнении 17 вводится альтернативный стиль с большим шагом (big-step), называемый также естественной семантикой (natural semantics) (Kahn, 1987), где единственный переход абстрактной машины сразу вычисляет окончательное значение терма.
3
Для денотационной семантики камнем преткновения оказались недетерминистские вычисления и параллелизм; для аксиоматической семантики — процедуры.
4
Некоторые специалисты предпочитают называть это отношение редукцией (reduction), а термин вычисление (evaluation) сохранять для варианта <<с большим шагом>>, который описан в упражнении 17 и напрямую сопоставляет термы и их окончательные значения.
5
В главе 12 мы увидим доказательство гарантии завершения с несколько более сложной структурой.

Chapter 4  Реализация арифметических выражений на языке ML

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

Код, представленный здесь (и во всех разделах этой книги, посвященных реализации), написан на популярном языке из семейства ML (Gordon, Milner, and Wadsworth, 1979), который называется Objective Caml, или, сокращенно, OCaml (Leroy, 2000, Cousineau and Mauny, 1998). Используется лишь небольшое подмножество языка OCaml; все программы легко можно переписать на любой другой язык. Основные требования к языку — автоматическое управление памятью (сборка мусора) и простота определения рекурсивных функций через сопоставление с образцом на структурных типах данных. Вполне подходят другие функциональные языки, например, Standard ML (Milner, Tofte, Harper, and MacQueen, 1997), Haskell (Hudak et al., 1992, Thompson, 1999) или Scheme (Kelsey, Clinger, and Rees, 1998, Dybvig, 1996) (с каким-либо расширением для сопоставления с образцом). Языки со сборкой мусора, но без сопоставления, например, Java (Arnold and Gosling, 1996) или чистая Scheme, для наших задач несколько тяжеловаты. Языки, в которых нет ни того, ни другого, как C (Kernighan and Ritchie, 1988), подходят еще меньше.1

4.1  Синтаксис

Сначала необходимо определить тип OCaml-значений, представляющий термы. Эта задача легко решается с помощью механизма задания типов в OCaml: вот объявление, представляющее собой прямой перевод грамматики со с. ??.

type term = TmTrue of info | TmFalse of info | TmIf of info * term * term * term | TmZero of info | TmSucc of info * term | TmPred of info * term | TmIsZero of info * term

Конструкторы от TmTrue до TmIsZero соответствуют различным видам вершин в синтаксических деревьях типа term; тип, следующий за словом of, для каждого случая указывает количество поддеревьев, растущих из вершины данного вида.

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

При определении отношения вычисления необходимо проверять, является ли терм числовым значением:

let rec isnumericval t = match t with TmZero(_) -> true | TmSucc(_,t1) -> isnumericval t1 | _ -> false

Это типичный пример рекурсивного определения через сопоставление с образцом в OCaml: isnumericval определяется как функция, которая, будучи вызвана с аргументом TmZero, возвращает true; будучи применена к TmSucc с поддеревом t1, вызывает себя рекурсивно с аргументом t1, чтобы проверить, является ли он числовым значением; а будучи вызвана с любым другим аргументом, возвращает false. Знаки подчеркивания в некоторых образцах — метки <<неважно>>, они сопоставляются с любым термом, который стоит в указанном месте; в первых двух предложениях с их помощью игнорируются аннотации info, а в последнем предложении с помощью такой метки сопоставляется любой терм. Ключевое слово rec говорит компилятору, что определение функции рекурсивно — т. е., что идентификатор isnumericval в теле функции относится к самой функции, которую сейчас определяют, а не к какой-либо более ранней переменной с тем же именем.

Заметим, что ML-код в приведенном определении при верстке немного <<украшен>>, как для простоты чтения, так и для сохранения стиля примеров из лямбда-исчисления. В частности, мы используем настоящий символ стрелки →, а не двухсимвольную последовательность ->.

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

let rec isval t = match t with TmTrue(_) -> true | TmFalse(_) -> true | t when isnumericval t -> true | _ -> false

В третьем предложении встречается <<условный образец>>: он сопоставляется с любым термом t, но только если булевское выражение isnumericval t возвращает значение <<истина>>.

4.2  Вычисление

Реализация отношения вычисления точно следует правилам одношагового вычисления по рис. 3.1 и 3.2. Как мы видели, эти правила определяют частичную функцию (partial function), которая, будучи применена к терму, не являющемуся значением, выдает следующий шаг вычисления этого терма. Если функцию попытаться применить к значению, она никакого результата не выдает. При переводе правил вычисления на OCaml нам нужно решить, как действовать в таком случае. Очевидный вариант — написать функцию одношагового вычисления eval1 так, чтобы она вызывала исключение, если ни одно из правил невозможно применить к терму, полученному в качестве параметра. (Другой способ действий заключается в том, чтобы заставить вычислитель возвращать значение типа term option, которое бы показывало, было ли вычисление успешным, и если да, то каков его результат; этот вариант тоже работал бы, но потребовал бы больше служебного кода.) Определим сначала исключение, которое вызывается, если ни одно правило не применимо:

exception NoRuleApplies

Теперь можно написать саму функцию одношагового вычисления.

let rec eval1 t = match t with TmIf(_,TmTrue(_),t2,t3) -> t2 | TmIf(_,TmFalse(_),t2,t3) -> t3 | TmIf(fi,t1,t2,t3) -> let t1' = eval1 t1 in TmIf(fi, t1', t2, t3) | TmSucc(fi,t1) -> let t1' = eval1 t1 in TmSucc(fi, t1') | TmPred(_,TmZero(_)) -> TmZero(dummyinfo) | TmPred(_,TmSucc(_,nv1)) when (isnumericval nv1) -> nv1 | TmPred(fi,t1) -> let t1' = eval1 t1 in TmPred(fi, t1') | TmIsZero(_,TmZero(_)) -> TmTrue(dummyinfo) | TmIsZero(_,TmSucc(_,nv1)) when (isnumericval nv1) -> TmFalse(dummyinfo) | TmIsZero(fi,t1) -> let t1' = eval1 t1 in TmIsZero(fi, t1') | _ -> raise NoRuleApplies

Заметим, что в нескольких местах мы создаем новые термы, а не переделываем уже существующие. Так как этих новых термов нет в пользовательских исходных файлах, то аннотации info в них не имеют смысла. В таких термах мы используем константу dummyinfo. Для сопоставления с аннотациями в образцах всегда используется переменная fi (file information, <<информация о файле>>).

Еще одна существенная деталь в определении eval1 — использование явных выражений when в образцах для передачи смысла имен метапеременных вроде v и nv в правилах, определяющих отношение вычисления на рис. 3.1 и 3.2. Например, в выражении для вычисления TmPred(_,TmSucc(_,nv1)), семантика образцов OCaml позволяет nv1 сопоставляться с каким угодно термом, а мы этого не хотим; добавление when (isnumericval nv1) ограничивает правило так, чтобы оно срабатывало только если терм, соответствующий nv1, действительно является числовым значением. (Мы, в принципе, могли бы переписать исходные правила вывода в том же стиле, что и образцы ML, превратив неявные ограничения, подразумеваемые именами метапеременных, в явные условия применения правил:

  
t1 — числовое значение
pred (succ t1) → t1

При этом несколько пострадала бы компактность правил и их удобство для чтения.)

Наконец, функция eval берет терм и находит его нормальную форму, циклически применяя eval1. Если eval1 возвращает новый терм t', то мы вызываем eval рекурсивно, чтобы продолжить вычисление, начиная с t'. Когда, наконец, eval1 достигнет состояния, где не применимо никакое правило, она вызывает исключение NoRuleApplies. При этом eval выходит из цикла и возвращает заключительный терм последовательности.2

let rec eval t = try let t' = eval1 t in eval t' with NoRuleApplies -> t

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

Упражнение 2   [Рекомендуется,★★★↛]:
Измените определение функции
eval в программе arith, используя стиль с большим шагом из упражнения 17.

4.3  Что осталось за кадром

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


>=stealth’, phase/.style= rectangle, rounded corners, draw=black, very thick, text width=10em, minimum height=3em, text centered, line/.style=draw=black, thin, ->,font=, every edge/.style=->,thick,shorten >=1pt

[phase] (io) Файловый вводвывод; [phase,right=1.6cm of io] (lexer) Лексический анализ; [phase,below=0.8cm of lexer] (syntax) Синтаксический анализ; [phase,below=0.8cm of syntax] (eval) Вычисления; [phase, left=1.6cm of eval] (print) Печать;

(io) edge [line] node[above] символы (lexer) (lexer) edge [line] node[right] лексемы (syntax) (syntax) edge [line] node[right] термы (eval) (eval) edge [line] node[above] значения (print) ;


Интересующиеся читатели могут исследовать имеющийся в сети код всего интерпретатора на OCaml.


1
Код из этой главы хранится в веб-репозитории по адресу http://www.cis.upenn.edu/~bcpierce/tapl под названием arith. Там же можно найти инструкции по скачиванию и сборке интерпретаторов.
1
Разумеется, языковые вкусы бывают разные, и хороший программист способен работать с любым инструментом; читатель может выбрать тот язык, который ему нравится. Однако следует иметь в виду, что при символьной обработке, характерной для программ проверки типов, ручное управление памятью особенно утомительно и чревато ошибками.
2
Мы записываем eval таким образом из соображений удобства для чтения, однако на самом деле помещать обработчик исключений try внутрь рекурсивного цикла в ML не рекомендуется.
Упражнение 1   [★★]:
Почему? Как было бы правильнее записать
eval?

Chapter 5  Бестиповое лямбда-исчисление

В этой главе мы вспомним определение и некоторые базовые свойства бестипового (untyped), или чистого (pure), лямбда-исчисления (lambda-calculus). Бестиповое лямбда-исчисление служит вычислительной основой, <<почвой>>, из которой появилось большинство систем типов, описываемых в оставшейся части книги.1

В середине 60-х годов Питер Ландин отметил, что сложный язык программирования можно изучать, сформулировав его ядро в виде небольшого базового исчисления, которое выражает самые существенные механизмы языка, и дополнив его набором удобных производных форм (derived forms), поведение которых можно выразить путем перевода на язык базового исчисления (Landin (1964), Landin (1965), Landin (1966); см. также Tennent (1981)). В качестве базового языка Ландин использовал лямбда-исчисление (lambda-calculus) — формальную систему, изобретенную в 1920-е годы Алонсо Чёрчем (Church, 1936, Church, 1941), где все вычисление сводится к элементарным операциям — определению функции и ее применению. Под влиянием идей Ландина, а также новаторских работ Джона Маккарти по языку Lisp (McCarthy, Russell, Edwards, et al., 1959, McCarthy, 1981) лямбда-исчисление стало широко использоваться для спецификации конструкций языков программирования, в разработке и реализации языков, а также в исследовании систем типов. Важность этого исчисления состоит в том, что его можно одновременно рассматривать как простой язык программирования, на котором можно описывать вычисления, и как математический объект, о котором можно доказывать строгие утверждения.

Лямбда-исчисление — лишь одно из нескольких фундаментальных исчислений, используемых для подобных целей. Пи-исчисление (pi-calculus) Милнера, Пэрроу и Уокера (Milner, Parrow, and Walker, 1992, Milner, 1991)) завоевало популярность как базовый язык для определения семантики языков параллельных вычислений с обменом сообщениями, а исчисление объектов (object calculus) Абади и Карделли (Abadi and Cardelli, 1996) выражает суть объектно-ориентированных языков. Большинство идей и методов, которые мы опишем и разработаем для лямбда-исчисления, можно без особого труда перенести и на эти другие исчисления. Один из примеров такого переноса представлен в главе 19.

Лямбда-исчисление можно расширить и обогатить несколькими способами. Во-первых, часто для удобства добавляют особый синтаксис для чисел, кортежей, записей и т. п., чье поведение, в принципе, можно смоделировать и в базовом языке. Интереснее добавить более сложные возможности, такие как изменяемые ссылочные ячейки или нелокальная обработка исключений. Эти свойства можно смоделировать на базовом языке только путем достаточно тяжеловесного перевода. Такие расширения, в конце концов, приводят к языкам вроде ML (Gordon, Milner, and Wadsworth, 1979, Milner, Tofte, and Harper, 1990, Weis, Aponte, Laville, Mauny, and Suárez, 1989, Milner, Tofte, Harper, and MacQueen, 1997), Haskell (Hudak et al., 1992) или Scheme (Sussman and Steele, 1975, Kelsey, Clinger, and Rees, 1998). Как мы увидим в последующих главах, расширения базового языка часто требуют расширения системы типов.

5.1  Основы

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

и переписать его в виде factorial(5) + factorial(7) - factorial(3), где

factorial(n) = if n=0 then 1 else n * factorial(n-1)

Для каждого неотрицательного числа n подстановка аргумента n в функцию factorial дает в результате факториал n. Используя нотацию <<λn. ...>> обозначающую <<функцию, которая для каждого n, дает …>>, определение factorial можно переформулировать как

factorial = λn. if n=0 then 1 else n * factorial(n-1)

Теперь factorial(0) означает <<функция λn. if n=0 then 1 else ..., примененная к аргументу 0>>, то есть, <<значение, которое получается, если аргумент n в теле функции (λn. if n=0 then 1 else ...) заменить на 0>>, то есть, <<if 0=0 then 1 else ...>>, то есть, 1.

Лямбда-исчисление (lambda-calculus) (или λ-исчисление) воплощает такой способ определения и применения функций в наиболее чистой форме. В лямбда-исчислении всё является функциями: аргументы, которые функции принимают — тоже функции, и результат, возвращаемый функцией — опять-таки функция.

Синтаксис лямбда-исчисления состоит из трех видов термов.1 Переменная x сама по себе есть терм; абстракция переменной x в терме t1, (записывается как λx.t1), — тоже терм; и, наконец, применение терма t1 к терму t2 (записывается t1 t2) — третий вид термов. Эти способы конструирования термов выражаются следующей грамматикой:

t ::= термы:
 xпеременная
 λx. tабстракция
 t tприменение


В следующих подразделах это определение изучается детально.

Абстрактный и конкретный синтаксис

При обсуждении синтаксиса языков программирования полезно различать два уровня2 структуры. Конкретный синтаксис (concrete syntax, или surface syntax) языка относится к строкам символов, которые непосредственно читают и пишут программисты. Абстрактный синтаксис (abstract syntax) — это намного более простое внутреннее представление программ в виде помеченных деревьев (они называются абстрактными синтаксическими деревьями (abstract syntax trees) или АСД (AST)). Представление в виде дерева делает структуру термов очевидной, и поэтому его удобно использовать для сложных преобразований, которые нужны как при строгом определении языков (и доказательстве их свойств), так и внутри компиляторов и интерпретаторов.

Преобразование из конкретного в абстрактный синтаксис происходит в два этапа. Сначала лексический анализатор (lexical analyzer) (или лексер, lexer) переводит последовательность символов, написанных программистом, в последовательность лексем (tokens) — идентификаторов, ключевых слов, комментариев, символов пунктуации, и т. п. Лексический анализатор убирает комментарии, обрабатывает пробелы, решает вопрос с заглавными и строчными буквами, а также распознает форматы числовых и символьных констант. После этого грамматический анализатор (парсер) (parser) преобразует последовательность лексем в абстрактное синтаксическое дерево. При грамматическом разборе соглашения о приоритете (precedence) и ассоциативности (associativity) операторов помогают уменьшить количество скобок в программе, явно указывающих структуру составных выражений. Например, оператор * имеет приоритет выше, чем оператор +, так что анализатор интерпретирует выражение без скобок 1+2*3 как абстрактное синтаксическое дерево, которое показано слева, а не справа:

[.+ 1 [.* 2 3 ] ]      [.* [.+ 1 2 ] 3 ]

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

Чтобы избежать излишних скобок, для записи лямбда-термов в линейной форме мы следуем двум соглашениям. Во-первых, применение функции лево-ассоциативно — то есть, s t u обозначает то же дерево, что (s t) u:

[.apply [.apply s t ] u ]

Во-вторых, тела абстракций простираются направо как можно дальше, так что, например, λx. λy. x y x означает то же самое, что и λx. (λy. ((x y) x)):

[.λx [.λy [.apply [.apply x y ] x ] ] ]

Переменные и метапеременные

Еще одна тонкость в приведенном определении синтаксиса касается использования метапеременных. Мы будем продолжать использовать метапеременную t (а также s и u, с нижними индексами и без них), обозначающую произвольный терм.3 Аналогично, x (а также y и z) замещает произвольную переменную. Заметим, что здесь x — это метапеременная, значениями которой являются другие переменные! К сожалению, число коротких имен ограничено, и нам потребуется иногда использовать x, y и т. д. для обозначения переменных объектного языка. В таких случаях, однако, из контекста всегда будет ясно, что имеется в виду. Например, в предложении <<Терм λx. λy. x y имеет вид λz. s, где z = x, а s = λy. x y>> имеем z и s — имена метапеременных, а x и y — имена переменных объектного языка.

Область видимости

Последнее, что нам требуется разъяснить в синтаксисе лямбда-исчисления, — область видимости (scope) переменных.

Переменная x называется связанной (bound), если она находится в теле t абстракции λx.t. (Точнее, оно связано этой абстракцией. Мы можем также сказать, что λx — связывающее определение (binder) с областью видимости t.) Вхождение x свободно (free), если оно находится в позиции, в которой оно не связано никакой вышележащей абстракцией переменной x. Например, вхождения x в x y и λy. x y свободны, а вхождения x в λx. x и λz. λx. λy. x (y z) связаны. В (λx. x) x первое вхождение x связано, а второе свободно.

Терм без свободных переменных называется замкнутым (closed); замкнутые термы называют также комбинаторами (combinators). Простейший комбинатор, называемый функцией тождества (identity function),

id = λx.x;

не выполняет никаких действий, а просто возвращает свой аргумент.

Операционная семантика

В своей чистой форме лямбда-исчисление не содержит встроенных констант и элементарных операторов — ни чисел, ни арифметических операций, ни условных выражений, ни записей, ни циклов, ни последовательного выполнения выражений, ни ввода-вывода, и т. д. Единственное средство для <<вычисления>> термов — применение функций к аргументам (которые сами являются функциями). Каждый шаг вычисления состоит в том, что в терме-применении, в котором левый член является абстракцией, связанная переменная в теле этой абстракции заменяется на правый член. Записывается это так:

(λx.t12) t2 → [x ↦ t2t12

где [xt2] t12 означает <<терм, получаемый из t12 путем замены всех свободных вхождений x на t2>>. Например, терм (λx. x) y за один шаг вычисления переходит в y, а терм (λx. x (λx. x)) (u r) переходит в u r (λx. x). Вслед за Чёрчем, терм вида (λx. t12) t2 называется редексом (redex) (reducible expression (redex), <<сокращаемое выражение>>), а операция переписывания редекса в соответствии с указанным правилом называется бета-редукцией (beta-reduction).

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

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

5.2  Программирование на языке лямбда-исчисления

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

Функции с несколькими аргументами

Заметим для начала, что в лямбда-исчислении отсутствует встроенная поддержка функций с несколькими аргументами. Разумеется, ее было бы нетрудно добавить, однако того же самого результата проще достичь через функции высшего порядка (higher-order functions), которые возвращают функции в качестве результата. Допустим, у нас есть терм s с двумя свободными переменными x и y, и мы хотим написать такую функцию f, которая выдавала бы для каждой пары аргументов (v,w) результат подстановки v вместо x и w вместо y. Мы пишем не f = λ(x,y).s, как мы сделали бы это в более богатом языке, а f = λx.λy.s. Это означает, что f есть функция, которая, получив значение v для параметра x, выдает функцию, которая, получив значение w для параметра y, выдает нужный результат. После этого мы поочередно применяем f к аргументам, получая запись f v w (т. е., (f v) w), которая переходит в ((λy.[xv]s) w), и далее в [yw][xv]s. Такое преобразование функций с несколькими аргументами в функции высшего порядка называется каррированием (currying) в честь Хаскелла Карри, современника Чёрча.

Булевские константы Чёрча

Еще одна языковая конструкция, легко кодируемая в лямбда-исчислении — булевские значения и условные выражения. Определим термы tru и fls таким образом:

tru = λt. λf. t; fls = λt. λf. f;

(Этим термам даны сокращенные имена, чтобы избежать их смешения с элементарными булевскими константами true и false из главы 3.)

Можно считать, что термы tru и fls представляют собой булевские значения <<истина>> и <<ложь>> в том смысле, что с их помощью мы можем выполнять операцию проверки булевского значения на истинность. А именно, мы можем определить комбинатор test, такой, что test b v w переходит в v, если b равно tru, и в w, если b равно fls.

test = λl. λm. λn. l m n;

Комбинатор test почти ничего не делает: test b v w просто переходит в b v w. В сущности, булевские значения являются условными выражениеми: они принимают два аргумента и выбирает из них либо первый (если это tru), либо второй (если это fls). Например, терм test tru v w редуцируется таким образом:

 test tru v w 
=(λl. λm. λn. l m b) tru v wпо определению
(λm. λn. tru m b) v wредукция подчеркнутого выражения
(λn. tru v b) wредукция подчеркнутого выражения
tru v wредукция подчеркнутого выражения
=(λt. λf. t) v wпо определению
(λf. v) wредукция подчеркнутого выражения
vредукция подчеркнутого выражения


Несложно также определить в виде функций такие булевские операторы, как логическая конъюнкция:

and = λb. λc. b c fls;

То есть, and — это функция, которая, получив два булевских значения b и c, возвращает c, если b равно tru и fls, если b равно fls; таким образом, and b c выдает tru, если и b, и c равны tru, и fls, если либо b, либо c окажутся равными fls.

and tru tru; |> (λt. λf. t) and tru fls; |> (λt. λf. f)
Упражнение 1   [★]: Определите логические функции or (<<или>>) и not (<<не>>).

Пары

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

pair = λf.λs.λb. b f s; fst = λp. p tru; snd = λp. p fls;

Это означает, что pair v w — функция, которая, будучи применена к булевскому значению b, применяет b к v и w. По определению булевских констант, при таком вызове получится v, если b равняется tru, и w, если b равняется fls, так что функции первой и второй проекции fst и snd можно получить, просто подав в пару соответствующие булевские значения. Вот как проверить, что fst (pair v w)* v:

 fst (pair v w) 
=fst ((λf.λs.λb. b f s) v w)по определению
fst ((λs.λb. b v s) w)редукция подчеркнутого выражения
fst (λb. b v w)редукция подчеркнутого выражения
=(λp. p tru) (λb. b v w)по определению
(λb. b v w) truредукция подчеркнутого выражения
tru v wредукция подчеркнутого выражения
*vкак показано ранее.


Числа Чёрча

После всего, что мы видели, представление чисел в виде лямбда-термов будет лишь ненамного сложней. Числа Чёрча (Church numerals) co, c1, c2, и т. д. можно определить таким образом:

c0 = λs. λz. z; c1 = λs. λz. s z; c2 = λs. λz. s (s z); c3 = λs. λz. s (s (s z));

и т. д. Здесь каждое число n представляется комбинатором cn, который принимает два аргумента, s и z (<<функцию следования>> и <<ноль>>), и n раз применяет s к z. Как и в случае с булевскими константами и парами, такое кодирование превращает числа в активные сущности: число n представляется в виде функции, которая что-то делает n раз — своего рода активное число по основанию 1.

(Читатель мог уже заметить, что c0 и fls записываются одним и тем же термом. Такие <<каламбуры>> часто встречаются в языках ассемблера, где одна и та же комбинация битов может представлять собой множество разных значений — целое число, число с плавающей точкой, адрес, четыре символа и т. п., — в зависимости от того, как интерпретируются биты. Аналогичная ситуация в низкоуровневых языках вроде C, где 0 и false тоже представляются одинаково.)

Функцию следования на числах Чёрча можно определить так:

scc = λn. λs. λz. s (n s z);

Терм scc — это комбинатор, который принимает число Чёрча n и возвращает другое число Чёрча, — то есть, возвращает функцию, которая принимает аргументы s и z, и многократно применяет s к z. Нужное число применений s к z мы получаем, сначала передав s и z в качестве аргументов n, а затем явным образом применив s еще раз к результату.

Упражнение 2   [★★]: Найдите ещё один способ определения функции следования на числах Чёрча.

Похожим образом, сложение на числах Чёрча можно осуществлять с помощью терма plus, который принимает в качестве аргументов два числа Чёрча, m и n, и возвращает еще одно число Чёрча — т. е., функцию, которая принимает аргументы s и z, применяет s к z n раз (передавая s и z в качестве аргументов n), а потом применяет s еще m раз к результату:

plus = λm. λn. λs. λz. m s (n s z);

Для реализации умножения используется еще один трюк: поскольку plus принимает аргументы по одному, применение его к одному аргументу n дает функцию, которая добавляет n к любому данному ей аргументу. Можно передать эту функцию в качестве первого аргумента m, а в качестве второго дать c0, и это будет означать <<применить функцию, добавляющую n к своему аргументу, к нулю и повторить m раз>>, т. е., <<сложить m копий числа n>>.

times = λm. λn. m (plus n) c0;
Упражнение 3   [★★]: Можно ли определить умножение на числах Чёрча без использования plus?
Упражнение 4   [Рекомендуется,★★]: Определите терм для возведения чисел в степень.

Чтобы проверить, является ли число Чёрча нулем, нужно найти какую-то подходящую для этой цели пару аргументов, — а именно, нужно применить указанное число к паре термов zz и ss, таких чтобы применение ss к zz один или более раз давало fls, а отсутствие применения давало tru. Понятно, что в качестве zz нужно просто взять tru. Для ss же мы используем функцию, которая игнорирует свой аргумент и всегда возвращает fls:

iszro = λm. m (λx. fls) tru; iszro c1; |> (λt. λf. f) iszro (times c0 c2); |> (λt. λf. t)

Как ни странно, определить вычитание на числах Чёрча намного сложнее, чем сложение. Для этого можно воспользоваться следующей довольно хитрой <<функцией предшествования>>, которая возвращает c0, если передать в качестве аргумента c0, и возвращает ci, если передать в качестве аргумента ci+1,:

zz = pair c0 c0; ss = λp. pair (snd p) (plus c1 (snd p)); prd = λm. fst (m ss zz);

Это определение работает так: мы используем m в качестве функции и применяем с ее помощью m копий функции ss к начальному значению zz. Каждая копия ss принимает в качестве аргумента пару чисел pair ci cj и выдает в результате пару pair cj cj+1 (см. рис. 5.1). Таким образом, при m-кратном применении ss к pair c0 c0 получается pair c0 c0, если m = 0, и pair cm-1 cm при положительном m. В обоих случаях, в первом компоненте пары находится искомый предшественник.


@C=.2em @R=1.5ex **[r] pair @/_2em/[dd]_ss c0c0 [ddl]_копия [dd]^+1

**[r] pair @/_2em/[dd]_ss c0c1 [ddl]_копия [dd]^+1

**[r] pair @/_2em/[dd]_ss c1c2 [ddl]_копия [dd]^+1

**[r] pair @/_2em/[dd]_ss c2c3 [ddl]_копия [dd]^+1

**[r] pair c3c4
**[r] ⋮
Figure 5.1: <<Внутренний цикл>> функции предшествования.

Упражнение 5   [★★]: Определите функцию вычитания при помощи prd .
Упражнение 6   [★★]: Сколько примерно шагов вычисления (в зависимости от n) требуется, чтобы получить prd cn?
Упражнение 7   [★★]: Напишите функцию equal, которая проверяет два числа на равенство и возвращает Чёрчеву булевскую константу. Например:
equal c3 c3 |> (λt. λf. t) equal c3 c2 |> (λt. λf. f)

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

Упражнение 8   [Рекомендуется,★★★]: Список можно представить в лямбда-исчислении через его функцию свертки fold. (В OCaml эта функция называется fold_left; ее иногда еще называют reduce.) Например, список [x, y, z] становится функцией, которая принимает два аргумента c и n, и возвращает c x (c y (c z n)). Как будет выглядеть представление nil? Напишите функцию cons, которая принимает элемент h и список (то есть, функцию свертки) t, и возвращает подобное представление списка, полученного добавлением h в голову t. Напишите функции isnil и head, каждая из которых принимает список в качестве параметра. Наконец, напишите функцию tail для такого представления списков (это намного сложнее; придется использовать трюк, аналогичный тому, что использовался при определении prd для чисел).

Расширенное исчисление

Мы убедились, что булевские значения, числа и операции над ними могут быть закодированы средствами чистого лямбда-исчисления. Строго говоря, все нужные нам программы мы можем писать, не выходя за рамки этой системы. Однако при работе с примерами часто бывает удобно включить в нее элементарные булевские значения и числа (а может быть, и другие типы данных). Если нам нужно совершенно точно указать, с какой системой мы в данный момент работаем, то для чистого лямбда-исчисления, определяемого на рис. 5.3, мы будем использовать обозначение λ, а для системы, в которую добавлены булевские и арифметические выражения с рис. 3.1 и 3.2 — обозначение λNB.

В λNB по сути есть две разные реализации булевских значений и две реализации чисел: настоящие и закодированные методом, описанным в этой главе. Мы можем выбирать между ними при написании программ. Разумеется, эти две реализации нетрудно преобразовать друг в друга. Чтобы перевести булевское значение по Чёрчу в элементарное булевское значение, нужно применить его к значениям true и false:

realbool = λb. b true false;

Для обратного преобразования используется условное выражение:

churchbool = λb. if b then tru else fls;

Можно встроить эти преобразования в операции высшего порядка. Вот проверка на равенство для чисел Чёрча, возвращающая настоящее логическое значение:

realeq = λm. λn. (equal m n) true false;

Аналогично мы можем преобразовать число Чёрча в соответствующее элементарное число, применив его к succ и 0:

realnat = λm. m (λx. succ x) 0;

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

Причины, по которым элементарные булевские и числовые значения оказываются полезны при работе с примерами, в основном связаны с порядком вычислений. Рассмотрим, например, терм scc c1. Исходя из приведенного выше обсуждения, мы могли бы ожидать, что он должен при вычислении давать число Чёрча c2. На самом деле этого не происходит:

scc c1; |> (λs. λz. s ((λs'. λz'. s' z') s z))

Этот терм содержит в себе редекс, который при вычислении привел бы нас (за два шага) к c2, однако согласно правилам вызова по значению мы ещё не можем сделать это, поскольку редекс находится внутри лямбда-абстракции.

Никакой фундаментальной проблемы здесь нет: терм, получающийся при вычислении scc c1, очевидным образом поведенчески эквивалентен (behaviorally equivalent) c2, в том смысле, что применение этого терма к паре аргументов v и w всегда даст тот же результат, что и применение c2 к тем же аргументам. Однако, необходимость доделать вычисление затрудняет проверку того, что наша функция scc ведет себя как надо. В случае более сложных арифметических вычислений трудность еще возрастает. Например, times c2 c2 дает в результате не c4, а такое чудовищное выражение:

times c2 c2; |> (λs. λz. (λs'. λz'. s' (s' z')) s ((λs'. λz'. (λs''. λz''. s'' (s'' z'')) s' ((λs''. λz''.z'') s' z')) s z))

Можно убедиться, что этот терм ведет себя так же, как c4, с помощью проверки на равенство:

equal c4 (times c2 c2); |> (λt. λf. t)

Однако более прямой способ — взять times c2 c2 и преобразовать в элементарное число:

realnat (times c2 c2); |> 4

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

Рекурсия

Вспомним, что терм, который не может продвинуться дальше согласно отношению вычисления, называется нормальной формой (normal form). Любопытно, что некоторые термы не могут быть вычислены до нормальной формы. Например, расходящийся комбинатор (divergent combinator)

omega = (λx. x x) (λx. x x);

содержит только один редекс, но шаг вычисления этого редекса дает в результате опять omega! Про термы, не имеющие нормальной формы, говорят, что они расходятся (diverge).

Комбинатор omega можно обобщить до полезного терма, который называется комбинатором неподвижной точки (fixed-point combinator),6 с помощью которого можно определять рекурсивные функции, например, factorial.7

fix = λf. (λx. f (λy. x x y)) (λx. f (λy. x x y));

Как и omega, комбинатор fix имеет сложную структуру с повторами; глядя на определение, трудно понять, как он работает. Вероятно, получить интуитивное представление о его поведении удобнее всего, рассмотрев его действие на конкретном примере.8 Допустим, мы хотим написать рекурсивное определение функции вида h = тело, содержащее h — т. е., построить такое определение, в котором правая часть использует саму функцию, которую мы определяем (например, как в определении факториала на с. ??). Идея состоит в том, чтобы рекурсивное определение <<разворачивалось>> там, где оно встретится. Например, факториалу интуитивно соответствует определение:

if n=0 then 1 else n * (if n-1=0 then 1 else (n-1) * (if n-2=0 then 1 else (n-2) * …))

или, в терминах чисел Чёрча,

if realeq n c0 then c1 else times n (if realeq (prd n) c0 then c1 else times (prd n) (if realeq (prd (prd n)) c0 then c1 else times (prd (prd n)) …))

Такого эффекта можно добиться при помощи комбинатора fix, сначала определив g = тело, содержащее f, а затем h = fix g. Например, функцию факториала можно определить через

g = λfct. λn. if realeq n c0 then c1 else (times n (fct (prd n))); factorial = fix g;

На рис. 5.2 показано, что происходит при вычислении с термом factorial c3. Ключевое свойство, которое обеспечивает работу этого вычисления, — это fct n * g fct n. Таким образом, fct — своего рода <<самовоспроизводящийся автомат>>, который, будучи применен к аргументу n, передает самого себя и n в качестве аргументов g. Там, где первый аргумент встречается в теле g, мы получим еще одну копию fct, которая, будучи применена к аргументу, опять передаст самое себя и аргумент внутрь g, и т. д. При каждом рекурсивном вызове с помощью fct мы разворачиваем очередную копию g и снабжаем ее очередными копиями fct, готовыми развернуться еще дальше.


 factorial c3
=fix g c3
h h c3
 где h = λx. g (λy. x x y)
g fct c3
 где fct = λy. h h y
(λn. if realeq n c0
       then c1
       else times n (fct (prd n)))
   c3
if realeq c3 c0
   then c1
   else times c3 (fct (prd c3)))
*times c3 (fct (prd c3))
*times c3 (fct c2)
 где c2 поведенчески эквивалентен c2
*times c3 (g fct c2)
*times c3 (times c2 (g fct c1))
 где c1 поведенчески эквивалентен c1
 (те же шаги повторяются для g fct c2)
*times c3 (times c2 (times c1 (g fct c0)))
 где c0 поведенчески эквивалентен c0
 (аналогично)
*times c3 (times c2 (times c1 (if realeq c0 c0 then c1
                               else …)))
*times c3 (times c2 (times c1 c1))
*c6
 где c6 поведенчески эквивалентен c6
Figure 5.2: Вычисление factorial c3

Упражнение 9   [★]: Почему в определении g мы использовали элементарную форму if, а не функцию test, работающую с Чёрчевыми булевскими значениями? Покажите, как определить функцию factorial при помощи test вместо if.
Упражнение 10   [★★]: Напишите функцию churchnat, переводящую элементарное натуральное число в представление Чёрча.
Упражнение 11   [Рекомендуется,★★]: При помощи fix и кодирования списков из упражнения 8 напишите функцию, суммирующую список, состоящий из чисел Чёрча.

Представление

Прежде чем закончить рассмотрение примеров и заняться формальным определением лямбда-исчисления, следует задаться еще одним, последним вопросом: что, строго говоря, означает утверждение, что числа Чёрча представляют обыкновенные числа?

Чтобы ответить на этот вопрос, вспомним, что такое обыкновенные числа. Существует много (эквивалентных) определений; в этой книге мы выбрали такое (рис. 3.2):

Поведение арифметических операций определяется правилами вычисления из рис. 3.2. Эти правила говорят нам, например, что 3 следует за 2, и что iszero 0 истинно.

Кодирование по Чёрчу представляет каждый из этих элементов в виде лямбда-терма (то есть, функции):

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

5.3  Формальности

В оставшейся части главы мы даем точное определение синтаксиса и операционной семантики лямбда-исчисления. По большей части, всё устроено так же, как в главе 3 (чтобы не повторять всё заново, мы здесь определяем только чистое лямбда-исчисление, без булевских значений и чисел). Однако операция подстановки терма вместо переменной связана с неожиданными сложностями.

Синтаксис

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

Определение 1   [Термы]: Пусть имеется счетное множество имен переменных V. Множество термов — это наименьшее множество T такое, что
  1. xT для всех xV;
  2. Если t1T и xV, то λx.t1T;
  3. Если t1T и t2T, то t1 t2T.

Размер (size) терма t можно определить точно так же, как мы это сделали для арифметических выражений в определении 2. Интереснее тот факт, что можно дать простое индуктивное определение множества свободных переменных, встречающихся в терме.

Определение 2   Множество свободных переменных (free variables) терма t записывается как FV(t) и определяется так:
    FV(x)={x}
    FV(λx. t1)=FV(t1) \ {x}
    FV(t1 t2)=FV(t1) ⋃ FV(t2)
  
Упражнение 3   [★★]: Постройте строгое доказательство утверждения: |FV(t)| ≤ size(t) для любого терма t.

Подстановка

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

Поучительно прийти к определению подстановки, сделав пару неудачных попыток. Проверим сначала самое наивное рекурсивное определение. (С формальной точки зрения, мы определяем функцию [xs] индукцией по аргументу t.):

[xs]x=s
[xs]y=yесли xy
[xs](λy. t1)=λy. [xs]t1
[xs](t1 t2)=([xs]t1) ([xs]t2)


Такое определение в большинстве случаев работает правильно. Например, оно дает

[x ↦ (λz. z w)](λy. x) = λy. λz. z w

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

[x ↦ y](λx.x) = λx.y

Это противоречит базовой интуитивной идее функциональной абстракции: имена связанных переменных не должны ни на что влиять — функция тождества остается самой собой, будь она записана в виде λx.x, λy.y или λfranz.franz. Если эти термы ведут себя по-разному при подстановке, они поведут себя по-разному и при редукции, а это явно неправильно.

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

[xs]x=s
[xs]y=y  если yx
[xs](λy. t1)= {
        λy. t1 
        λy. [xs]t1 
      
если y = x
если yx
[xs](t1 t2)=([xs]t1)   ([xs]t2)


Это уже лучше, но все-таки еще не вполне правильно. Посмотрим, например, что получается, когда мы пытаемся подставить терм z вместо переменной x в терме λz.x:

[x ↦ z](λz.x) = λz. z

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

Ситуация, в которой свободные переменные терма s становятся связанными при их наивной подстановке в терм t, называется захватом переменных (variable capture). Чтобы избежать его, нужно убедиться в том, что имена связанных переменных в t отличаются от имен свободных переменных в s. Операция подстановки, которая работает именно так, называется подстановкой, свободной от захвата (capture-avoiding substitution). (Обычно, когда просто говорят <<подстановка>>, именно такую подстановку и имеют в виду.) Мы можем добиться требуемого эффекта, если добавим ко второму варианту еще одно условие при подстановке в терм-абстракцию:

[xs]x=s
[xs]y=y  если yx
[xs](λy. t1)= {
        λy. t1 
        λy. [xs]t1 
      
если y = x
если yx и yFV(s)
[xs](t1 t2)=([xs]t1)   ([xs]t2)


Теперь почти все правильно: наше определение подстановки делает то, что требуется, когда оно вообще что-то делает. Проблема заключается в том, что последнее изменение превратило подстановку из полной функции в частичную. Например, новое определение не выдает никакого результата для [xy z](λy. x y): связанная переменная y терма, в который производится подстановка, не равна x, но она встречается как свободная в терме (y z), и ни одна строка определения к ней не применима.

Распространенное решение этой проблемы в литературе по системам типов и лямбда-исчислению состоит в том, что термы рассматриваются <<с точностью до переименования переменных>>. (Чёрч называл операцию последовательного переименования переменных в терме альфа-конверсией (alpha-conversion). Этот термин употребляется и до сих пор — мы могли бы сказать, что рассматриваем термы <<с точностью до альфа-конверсии>>.)

Соглашение 4   Термы, отличающиеся только именами связанных переменных, взаимозаменимы во всех контекстах.

На практике это означает, что имя любой λ-связанной переменной можно заменить на другое (последовательно проведя это переименование в теле λ) всегда, когда это оказывается удобным. Например, если мы хотим вычислить [xy z](λy. x y), мы сначала переписываем (λy. x y) в виде, скажем, (λw. x w). Затем мы вычисляем [xy z](λw. x w), что дает нам (λw. y z w).

Это соглашение делает наше определение <<практически полным>>, поскольку каждый раз, как мы пытаемся его применить к аргументам, к которым оно неприменимо, мы можем исправить дело переименованием, так, чтобы все условия выполнялись. В сущности, приняв это соглашение, мы можем сформулировать определение подстановки чуть короче. Мы можем отбросить первый вариант в определении для абстракций, поскольку всегда можно предположить (применяя, если надо, переименование), что связанная переменная y отличается как от x, так и от свободных переменных s. Определение принимает окончательный вид.

Определение 5   [Подстановка]:
[xs]x=s
[xs]y=yесли yx
[xs](λy.t1)=λy. [xs]t1 если yx и yFV(s)
[xs](t1 t2)=([xs]t1)   ([xs]t2)

Операционная семантика

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


(бестиповое)



Синтаксис
t::= термы:
x переменная
λx.t абстракция
t t применение
 
v::= значения:
λx.t значение-абстракция
Вычислениеtttt
      
t1 → t1
t1 t2 → t1 t2
 
      
t2 → t2
v1 t2 → v1 t2
 
      
(λx.t12) v2 → [x ↦ v2]t12
 



Figure 5.3: Бестиповое лямбда-исчисление (λ)


Отношение вычисления показано в правом столбце рисунка. Как и в случае арифметических выражений, имеется два типа правил: рабочее правило E-AppAbs и правила соответствия E-App1 и E-App2.

Обратите внимание, как выбор метапеременных в этих правилах помогает управлять порядком вычислений. Поскольку v2 может относиться только к значениям, левая сторона правила E-AppAbs соответствует всем применениям термов, в которых терм-аргумент является значением. Точно так же, E-App1 относится к тем применениям, в которых левая часть не является значением, поскольку t1 может обозначать любой терм, но предпосылка правила требует, чтобы t1 мог совершить шаг вычисления. Напротив, E-App2 не срабатывает, пока левая часть не станет значением, которое может быть обозначено метапеременной v. Совместно эти правила полностью определяют порядок вычисления терма вида t1 t2: сначала работает E-App1, пока t1 не сведется к значению, затем E-App2 применяется до тех пор, пока t2 не окажется значением, и, наконец, само правило E-AppAbs производит само применение.

Упражнение 6   [★★]:
Модифицируйте эти правила так, чтобы описать три другие стратегии вычисления: полную бета-редукцию, нормальный порядок и ленивое вычисление.

Заметим, что в чистом лямбда-исчислении единственные возможные значения — это лямбда-абстракции, так что, если E-App1 доводит t1 до значения, это значение должно быть лямбда-абстракцией. Разумеется, это утверждение перестает быть справедливым, как только мы добавляем в язык другие конструкции, скажем, элементарные булевские значения, поскольку при этом у нас появляются новые виды значений.

Упражнение 7   [★★,↛]:
В упражнении 
16 дается альтернативное представление операционной семантики булевских и арифметических выражений, в котором тупиковые термы дают при вычислении особую константу wrong. Распространите эту семантику на λNB.
Упражнение 8   [★★]:
В упражнении 
17 вводится стиль вычисления арифметических выражений <<с большим шагом>>, в котором базовое отношение вычисления означает <<терм t при вычислении дает окончательный результат v>>. Покажите, как сформулировать правила вычисления лямбда-термов в этом стиле.

5.4  Дополнительные замечания

Бестиповое лямбда-исчисление было разработано Чёрчем и его коллегами в 20-е и 30-е годы (Church, 1941). Основополагающий труд по всем вопросам бестипового лямбда-исчисления — книга Барендрегта (Barendregt, 1984); работа Хиндли и Селдина (Hindley and Seldin, 1986) уже по охвату, но легче для чтения. Статья Барендрегта (Barendregt, 1990) в <<Справочнике по теоретической информатике>> представляет собой краткий обзор. Сведения о лямбда-исчислении можно найти также во множестве учебников по функциональным языкам программирования (Abelson and Sussman, 1985, Friedman, Wand, and Haynes, 2001, Peyton Jones and Lester, 1992) и по семантике языков программирования (напр., Schmidt, 1986, Gunter, 1992, Winskel, 1993, Mitchell, 1996). Систематический метод кодирования различных структур данных в виде лямбда-термов описан в статье Бёма и Берардуччи (Böhm and Berarducci, 1985).

Несмотря на название, Карри не считал себя автором идеи каррирования. Её открытие обычно приписывают Шёнфинкелю (Schönfinkel, 1924), однако основная идея еще в XIX веке была известна некоторым математикам, в том числе Фреге и Кантору.

Возможно, у этой системы найдутся приложения не только в роли логического исчисления. Алонсо Чёрч, 1932


1
Примеры из этой главы являются термами чистого бестипового лямбда-исчисления, λ (см. рис. 5.3), либо лямбда-исчисления с добавлением булевских значений и арифметических операций, λNB (3.2). Соответствующая реализация на OCaml называется fulluntyped.
1
Выражение <<лямбда-терм (lambda-term)>> относится ко всем термам лямбда-исчисления. Лямбда-термы, которые начинаются с буквы λ, часто называют <<лямбда-абстракциями (lambda-abstractions)>>.
2
Определения полноценных языков иногда используют еще большее число уровней. Например, вслед за Ландином, часто бывает полезно определять поведение некоторых конструкций языка в качестве производных форм, путем перевода их в комбинации других, более элементарных, конструкций. Ограниченный язык, состоящий только из этих базовых конструкций, часто называют внутренним языком (internal language), а полный язык, который включает в себя все производные формы, называется внешним языком (external language). Трансформация из внешнего языка во внутренний выполняется (по крайней мере, концептуально) отдельным проходом компилятора, вслед за синтаксическим анализом. Производные формы обсуждаются в разделе 11.3.
3
Само собой, в этой главе t обозначает лямбда-терм, а не арифметическое выражение. На протяжении всей книги t будет обозначать терм того исчисления, которое обсуждается в данный момент. В начале каждой главы есть примечание, где указано, какая система рассматривается в этой главе.
4
Некоторые исследователи используют термины <<редукция>> и <<вычисление>> как синонимы. Другие называют <<вычислением>> только те стратегии, где какую-то роль имеет понятие <<значения>>, а в остальных случаях говорят о <<редукции>>.
5
В нашем <<фундаментальном>> исчислении значениями являются только лямбда-абстракции. В более богатых исчислениях будут присутствовать и другие виды значений: числовые и булевские константы, строки, кортежи значений, записи, состоящие из значений, списки значений и т. п.
6
Его также часто называют Y-комбинатором с вызовом по значению (call-by-value Y-combinator). Плоткин (Plotkin, 1975) использовал обозначение Z.
7
Заметим, что более простой комбинатор неподвижной точки с вызовом по имени:
Y = λf. (λx. f (x x)) (λx. f (x x))
при использовании вызова по значению бесполезен, поскольку при любом g выражение Y g расходится.
8
Определение fix также можно вывести непосредственно из базовых принципов (например, в Friedman and Felleisen, 1996, глава 9), однако такой вывод тоже достаточно хитроумен.
9
Строго говоря, по нашему определению, iszro t вычисляется в представление true в виде терма, но для простоты обсуждения мы забудем про это различие. Можно аналогичным образом выстроить объяснение того, как именно Чёрчевы булевские константы представляют настоящие булевские значения.

Chapter 6  Представление термов без использования имен

В предыдущей главе мы работали с термами <<с точностью до переименования связанных переменных>>. Мы договорились, что связанные переменные можно в любой момент переименовать, чтобы провести подстановку или если новое имя по каким-то причинам удобнее. В сущности, <<внешний вид>> имени связанной переменной может быть любым. Такое соглашение отлично работает при обсуждении основных идей лямбда-исчисления и помогает понятно записывать доказательства. Однако, при реализации исчисления в программе нам нужно иметь единое представление для каждого терма; в частности, требуется решить, как будут представлены вхождения переменных. Есть несколько способов решить эту задачу:1

  1. Можно представлять переменные символически, как мы это делали до сих пор, однако вместо неявного переименования мы при необходимости явно заменяем при подстановке связанные переменные <<свежими>> именами, чтобы избежать захвата.
  2. Можно представлять переменные символически, но потребовать, чтобы имена всех связанных переменных отличались друг от друга и от всех где-либо встречающихся свободных переменных. Такое соглашение (иногда его называют соглашение Барендрегта, Barendregt convention) более строго, чем наше, поскольку не разрешает переименовывать переменные <<на ходу>> в произвольные моменты. Однако это правило не безопасно относительно подстановки (или бета-редукции): поскольку подставляемый терм копируется, то нетрудно построить примеры, где в результате подстановки получается терм, в котором у нескольких λ-абстракций будет одно и то же имя связанной переменной. Следовательно, после каждого шага вычисления, включающего подстановку, должен следовать шаг переименования, восстанавливающий инвариант.
  3. Можно сконструировать некоторое <<каноническое>> представление переменных и термов, при котором переименование не нужно.
  4. Можно вообще избежать понятия подстановки с помощью механизмов вроде явных подстановок (explicit substitutions) (Abadi, Cardelli, Curien, and Lévy, 1991a).
  5. Можно избежать использования переменных (variables), если работать в языке, основанном на комбинаторах, например, комбинаторной логике (combinatory logic) (Curry and Feys, 1958, Barendregt, 1984) — варианте лямбда-исчисления, в котором вместо процедурной абстракции используются комбинаторы, — или на языке Бэкуса FP (Backus, 1978).

У каждой из этих схем есть свои сторонники, и выбор между ними — до некоторой степени дело вкуса (в серьезных реализациях компиляторов следует также учитывать соображения производительности, но нас они сейчас не волнуют). Мы выбираем третий вариант, который, по нашему опыту, будет лучше масштабироваться, когда потребуется работать с некоторыми более сложными интерпретаторами из этой книги. Его преимущество в том, что при ошибках реализации алгоритм сразу же выдаёт очевидно ошибочные результаты в самых простых случаях. Это позволяет достаточно быстро обнаруживать и исправлять ошибки. Напротив, известны случаи, когда в реализациях, основанных на именованных переменных, ошибки обнаруживались спустя месяцы и годы. Наша реализация использует хорошо известный метод, изобретенный Николасом де Брауном (de Bruijn, 1972).

6.1  Термы и контексты

Идея де Брауна состояла в том, чтобы представлять термы более естественным — хотя и более трудным для чтения — образом, обеспечив во вхождениях переменных прямые указания на их связывающие определения, вместо того, чтобы называть их по имени. Для этого можно заменить именованные переменные натуральными числами так, чтобы число k означало <<переменная, связанная k-й охватывающей λ>>. Например, обыкновенный терм λx.x соответствует безымянному терму (nameless term) λ.0, а терму λx.λy. x (y x) соответствует λ.λ. 1 (0 1). Безымянные термы иногда еще называют термами де Брауна (de Bruijn terms), а нумерованные переменные в них называются индексами де Брауна (de Bruijn indices).1Разработчики компиляторов используют для этого понятия термин <<статические расстояния>>.

Упражнение 1   [★]: Для каждого из следующих комбинаторов
c0 = λs. λz. z; c2 = λs. λz. s (s z); plus = λm. λn. λs. λz. m s (n s z); fix = λf. (λx. f (λy. (x x) y)) (λx. f (λy. (x x) y)); foo = (λx. (λx. x)) (λx. x);
запишите соответствующий безымянный терм.

Формально мы определяем синтаксис безымянных термов почти так же, как определялся синтаксис обыкновенных термов (5.3). Единственное различие состоит в том, что требуется внимательно следить, сколько свободных переменных может содержать каждый терм. То есть, требуется различать множества термов без свободных переменных (которые называются 0-термами, 0-terms), термов, в которых есть максимум одна свободная переменная (1-термы), и так далее.

Определение 2   [Термы]: Пусть T — наименьшее семейство множеств {T0, T1, T2, …}, такое, что
  1. kTn, если 0 ≤ k < n;
  2. если t1Tn и n > 0, то λ.t1Tn−1;
  3. если t1Tn и t2Tn, то (t1 t2)Tn.
(Заметим, что мы имеем здесь стандартное индуктивное определение, но определяем семейство множеств, индексируемое числами, а не одно множество.) Элементы каждого множества Tn называются n-термами.

Элементы Tn — это термы с не более, чем n переменных, пронумерованных от 0 до n−1: каждый элемент Tn не обязан содержать свободные переменные со всеми этими номерами, да и вообще не обязан иметь какие-либо свободные переменные. В частности, если t замкнут, он является элементом Tn для любого n.

Заметим, что всякий (замкнутый) обыкновенный терм имеет ровно одно представление де Брауна, и что два обыкновенных терма эквивалентны с точностью до переименования связанных переменных тогда и только тогда, когда у них одинаковые представления де Брауна.

Чтобы работать с термами, содержащими свободные переменные, нам потребуется понятие контекста именования (naming context). Например, допустим, нам нужно представить λx. y x в виде безымянного терма. Мы знаем, что делать с x, но не знаем, как вести себя с y, поскольку неизвестно, как <<далеко>> эта переменная будет определена, и какой ей сопоставить номер. Решение состоит в том, чтобы выбрать, раз и навсегда, присвоение индексов де Брауна свободным переменным (называемое контекстом именования), и последовательно использовать это присвоение, когда требуется выбрать номер для свободной переменной. Например, предположим, что мы решили работать в следующем контексте именования:

  Γ=     x ↦ 4 
  y ↦ 3 
  z ↦ 2 
  a ↦ 1 
  b ↦ 0 

Тогда x (y z) будет представлен как 4 (3 2), в то время как λw. y w будет представлен как λ. 4 0, а λw.λa.x — как λ.λ.6.

Поскольку порядок, в котором переменные следуют в Γ, однозначно определяет их числовые индексы, мы можем кратко записать контекст в виде последовательности.

Определение 3   Допустим, x0,… xn — имена переменных из V. Контекст именования Γ = xn, xn−1, … x1, x0 присваивает каждой xi индекс де Брауна i. Заметим, что самая правая переменная в контексте получает индекс 0; это соответствует тому, как мы считаем λ-связывания — справа налево, — когда преобразуем именованный терм в безымянный. Множество {xn, …, x0} переменных, упомянутых в Γ, мы обозначаем dom(Γ).
Упражнение 4   [★★★,↛]: Постройте альтернативную конструкцию множеств n-термов в стиле определения 3, и покажите (как в утверждении 6), что ваше определение эквивалентно вышеприведенному.
Упражнение 5   [Рекомендуется,★★★]:
  1. Определите функцию removenamesΓ(t), которая принимает контекст именования Γ и обыкновенный терм t (где FV(t) ⊆ dom(Γ)), и порождает соответствующий безымянный терм.
  2. Определите функцию restorenamesΓ(t), которая принимает безымянный терм t и контекст Γ, и порождает обыкновенный терм. (В процессе вам придется <<выдумывать>> имена переменных, связанных абстракциями в t. Можете предположить, что имена в V различаются попарно, и что множество имен переменных V упорядочено, так что выражение <<возьмем первое имя переменной, которое еще не содержится в dom(Γ)>> имеет смысл.)
Эта пара функций должна иметь свойство
removenamesΓ(restorenamesΓ(t)) = t
для любого безымянного терма t и, соответственно,
restorenamesΓ(removenamesΓ(t)) = t
с точностью до переименования связанных переменных, для любого обыкновенного терма t.

Строго говоря, нельзя говорить о <<некотором tT>> — всегда нужно указывать, сколько свободных переменных t может иметь. Однако на практике мы обычно будем иметь в виду некоторый заранее заданный контекст именования Γ; мы будем несколько вольно обращаться с нотацией и писать tT, имея в виду tTn, где n — длина Γ.

6.2  Сдвиг и подстановка

Нашей следующей задачей будет определение операции подстановки ([ks]t) на безымянных термах. Для этого потребуется вспомогательная операция, называемая <<сдвигом>>, которая перенумеровывает индексы свободных переменных в терме.

Когда подстановка проникает внутрь λ-абстракции, к примеру, [1s](λ.2) (т. е., [xs]λy.x, если предположить, что 1 — индекс переменной x во внешнем контексте), контекст, в котором происходит подстановка, становится длиннее исходного на одну переменную; требуется увеличить индексы свободных переменных в s, чтобы в новом контексте они ссылались на те же переменные, что и раньше. Однако делать это нужно осторожно: нельзя просто увеличить на единицу все индексы переменных s, потому что при этом сдвинулись бы и связанные переменные внутри s. Например, пусть s = 2 (λ.0) (т. е., s = z (λw.w), если 2 — индекс z во внешнем контексте). В этом случае нам требуется сдвинуть 2, но не 0. Функция сдвига, описанная ниже, принимает параметр <<отсечки>> c, управляющий тем, какие переменные сдвигаются. Исходно он равен 0 (что означает, что сдвигать нужно все переменные), и увеличивается каждый раз, когда функция сдвига пересекает границу абстракции. Таким образом, при вычислении ↑cd(t) мы знаем, что терм t происходит изнутри c слоев абстракции по отношению к исходному аргументу ↑d. Получается, что все идентификаторы k < c внутри t связаны в исходном аргументе и сдвигу не подлежат, а идентификаторы kc свободны, и их нужно сдвинуть.

Определение 1   [Сдвиг]: Сдвиг терма t на d позиций с отсечкой c, обозначаемый cd(t), определяется так:
    ↑cd(k)=
       


           kесли  k < c
           k + dесли  k ≥ c
     ↑cd(λ.t1)=λ. c+1d(t1) 
     ↑cd(t1 t2)=     ↑cd(t1)   ↑cd(t2
  
Запись d(t) означает 0d(t).
Упражнение 2   [★]:
  1. Чему равняется 2 (λ.λ. 1 (0 2))?
  2. Чему равняется 2 (λ. 0 1 (λ. 0 1 2))?
Упражнение 3   [★★,↛]: Покажите, что если t является n-термом и, если d<0, все свободные переменные t не меньше |d|, то cd(t) является max(n+d,0)-термом.

Теперь мы готовы определить оператор подстановки [js]t. Когда мы используем подстановку, нас обычно интересует подстановка последней переменной в контексте (т. е., j = 0), поскольку именно этот случай нам нужен, чтобы определить операцию бета-редукции. Однако для того, чтобы подставить значение переменной 0 в терме, который является лямбда-абстракцией, нужна возможность подстановки значения переменной 1 в теле этой абстракции. Таким образом, определение подстановки должно работать с произвольной переменной.

Определение 4   [Подстановка]: Подстановка терма s вместо переменной номер j в терме t, записываемая в виде [js]t, определяется следующим образом:
     [j ↦ sk=
       


           sесли  k = j
           kв противном случае
[j ↦ s(λ.t1)=       λ. [j+1 ↦ ↑1s]t1 
[j ↦ s(t1 t2)=       ([j ↦ st1   [j ↦ st2
   
Упражнение 5   [★]: Переведите следующие примеры подстановок в безымянную форму в предположении глобального контекста Γ = a, b и вычислите результаты подстановки по определению 4. Соответствуют ли ответы исходному определению подстановки на обыкновенных термах из §5?
  1. [ba] (b (λx.λy.b))
  2. [ba (λz. a)] (b (λx.b))
  3. [ba] (λb. b a)
  4. [ba] (λa. b a)
Упражнение 6   [★★,↛]: Покажите, что если s и tn-термы, и jn, то [js]tn-терм.
Упражнение 7   [★,↛]: Возьмите лист бумаги и, не глядя на определения подстановки и сдвига, сочините их заново.
Упражнение 8   [Рекомендуется,★★★]: Определение подстановки на безымянных термах должно быть согласовано с нашим неформальным определением подстановки на обыкновенных термах. (1) Какую теорему нужно доказать для того, чтобы строго обосновать это утверждение? (2) Докажите ее.

6.3  Вычисление

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

Нетривиальное обстоятельство состоит в том, что редукция редекса <<расходует>> связанную переменную: при редукции из ((λx.t12)v2) в [xv2]t12 связанная переменная x исчезает. Таким образом, переменные в результате подстановки надо перенумеровать, чтобы отразить тот факт, что x больше не является частью контекста. Например:

(λ. 1 0 2) (λ. 0) → 0 (λ.0) 1(а не 1 (λ.0) 2)

Аналогично, требуется сдвинуть переменные в v2 перед подстановкой в t12, поскольку терм t12 определен в более крупном контексте, чем v2. Собирая все эти соображения вместе, получаем такое правило бета-редукции:

  
(λ.t12) v2 →  ↑−1 ([0 ↦ ↑1(v2)]t12)

Остальные правила остаются такими же, как и раньше (рис. 5.3).

Упражнение 1   [★]: Должен ли нас беспокоить тот факт, что отрицательный сдвиг в правиле может создать некорректные термы с отрицательными индексами переменных?
Упражнение 2   [★★★]: В исходной статье де Брауна описано два разных способа получения безымянного представления термов: индексы де Брауна, которые нумеруют связывающие выражения <<изнутри наружу>>, и уровни де Брауна (de Bruijn levels), которые нумеруют связывающие выражения <<снаружи внутрь>>. Например, терм λx. (λy. x y) x представляется индексами де Брауна в виде λ. (λ. 1 0) 0, а уровнями де Брауна — в виде λ. (λ. 0 1) 0. Определите этот вариант с должной строгостью, и покажите, что представления термов с использованием индексов и уровней изоморфны (т. е., каждое из них можно однозначно восстановить, исходя из другого).

1
В этой главе изучается бестиповое лямбда-исчисление, λ (рис. 5.3). Соответствующая реализация на OCaml называется fulluntyped.
1
Фамилия de Bruijn читается <<де Браун>>. В русскоязычной литературе встречаются варианты транслитерации <<де Брейн>>, <<де Брюйн>> и <<де Бройн>>. — прим. перев.

Chapter 7  Реализация лямбда-исчисления на ML

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

Выполняемый вычислитель бестиповых лямбда-термов может быть получен путем простого перевода на OCaml определений из предыдущих глав. Как и в главе 4, мы демонстрируем только базовые алгоритмы, игнорируя вопросы лексического и синтаксического анализа, ввода-вывода и тому подобные детали.1

7.1  Термы и контексты

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

type term = TmVar of int | TmAbs of term | TmApp of term * term

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

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

type term = TmVar of info * int | TmAbs of info * term | TmApp of info * term * term

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

type term = TmVar of info * int * int | TmAbs of info * term | TmApp of info * term * term

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

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

type term = TmVar of info * int * int | TmAbs of info * string * term | TmApp of info * term * term

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

Сама процедура печати выглядит так:

let rec printtm ctx t = match t with TmAbs(fi,x,t1) -> let (ctx',x') = pickfreshname ctx x in pr "(lambda "; pr x'; pr ". "; printtm ctx' t1; pr ")" | TmApp(fi, t1, t2) -> pr "("; printtm ctx t1; pr " "; printtm ctx t2; pr ")" | TmVar(fi,x,n) -> if ctxlength ctx = n then pr (index2name fi ctx x) else pr "[bad index]"

В ней используется тип данных context,

type context = (string * binding) list

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

type binding = NameBind

и не несут никакой дополнительной информации. Однако позднее (в главе 10) мы введем в определение типа binding другие варианты, которые будут отслеживать сведения о типе, связанном с переменной, и другую подобную информацию.

Процедура распечатки также использует нескольких низкоуровневых функций: pr выдает строку в стандартный поток вывода; ctxlength возвращает длину контекста; index2name находит строковое имя переменной по ее индексу. Самая интересная из этих функций — pickfreshname, которая принимает контекст ctx и имя-подсказку x, находит имя x’, похожее на x и не встречающееся в ctx, добавляет x’ к контексту ctx, получая при этом новый контекст ctx’, и возвращает пару, состоящую из x’ и ctx’.

Реальная процедура печати в интерпретаторе untyped, имеющемся на веб-сайте книги, выглядит несколько сложнее, поскольку принимает во внимание еще два обстоятельства. Во-первых, она по возможности избегает печатать скобки, следуя соглашению о том, что применение право-ассоциативно, а тела абстракций простираются насколько возможно вправо. Во-вторых, она порождает команды форматирования для OCaml-библиотеки Format — низкоуровневого модуля красивой печати (pretty printing), который принимает решения о переводах строк и отступах.

7.2  Сдвиг и подстановка

Определение сдвига (1) переводится на OCaml практически посимвольно.

let termShift d t = let rec walk c t = match t with TmVar(fi,x,n) -> if x>=c then TmVar(fi,x+d,n+d) else TmVar(fi,x,n+d) | TmAbs(fi,x,t1) -> TmAbs(fi, x, walk (c+1) t1) | TmApp(fi,t1,t2) -> TmApp(fi, walk c t1, walk c t2) in walk 0 t

Внутренний сдвиг ↑cd(t) здесь представлен вызовом внутренней функции walk c t. Поскольку d не меняется, нет нужды передавать ее в каждый вызов walk: когда это требуется в варианте с переменной внутри walk, мы просто используем внешнее связывание d. Сдвиг верхнего уровня ↑d(t) представляется выражением termShift d t. (Заметим, что сама функция termShift не помечена как рекурсивная, поскольку ее единственная задача — один раз вызывать walk.)

Аналогично, функция подстановки получается почти напрямую из определения 4:

let termSubst j s t = let rec walk c t = match t with TmVar(fi,x,n) -> if x=j+c then termShift c s else TmVar(fi,x,n) | TmAbs(fi,x,t1) -> TmAbs(fi, x, walk (c+1) t1) | TmApp(fi,t1,t2) -> TmApp(fi, walk c t1, walk c t2) in walk 0 t

Подстановка [js]t терма s вместо переменной с номером j в терме t записывается здесь в виде termSubst j s t. Единственное отличие от исходного определения подстановки состоит в том, что весь сдвиг s производится сразу, в ветви TmVar, вместо того, чтобы сдвигать s на единицу при каждом проходе через связывание. При этом получается, что аргумент j во всех вызовах walk один и тот же, и во внутреннем определении его можно опустить.

Читатель может заметить, что определения termShift и termSubst весьма похожи, и отличаются только действием, производимым, когда процесс достигает переменной. Интерпретатор untyped, имеющийся на веб-сайте книги, использует это сходство и выражает как сдвиг, так и подстановку в качестве частных случаев более общей функции tmmap. Принимая терм t и функцию onvar, tmmap onvar t выдает терм той же формы, что t, в котором каждая переменная заменена на результат вызова onvar от этой переменной. В более крупных исчислениях такой прием избавляет нас от довольно большого количества повторений; подробности можно найти в §25.2.

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

let termSubstTop s t = termShift (-1) (termSubst 0 (termShift 1 s) t)

7.3  Вычисление

Как и в главе 4, функция вычисления зависит от вспомогательного предиката isval:

let rec isval ctx t = match t with TmAbs(_,_,_) -> true | _ -> false

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

let rec eval1 ctx t = match t with TmApp(fi,TmAbs(_,x,t12),v2) when isval ctx v2 -> termSubstTop v2 t12 | TmApp(fi,v1,t2) when isval ctx v1 -> let t2' = eval1 ctx t2 in TmApp(fi, v1, t2') | TmApp(fi,t1,t2) -> let t1' = eval1 ctx t1 in TmApp(fi, t1', t2) | _ -> raise NoRuleApplies

Функция многошагового вычисления такая же, как раньше, за исключением аргумента ctx:

let rec eval ctx t = try let t' = eval1 ctx t in eval ctx t' with NoRuleApplies -> t
Упражнение 1   [Рекомендуется,★★★↛]: Измените реализацию так, чтобы она использовала стиль вычисления с <<большим шагом>>, введенный в упражнении 17.

7.4  Дополнительные замечания

Реализация подстановок, представленная в этой главе, хотя и достаточна для целей нашей книги, но далека от идеала. В частности, правило бета-редукции в нашем интерпретаторе <<энергично>> подставляет значение аргумента вместо связанной переменной в теле процедуры. Интерпретаторы (и компиляторы) функциональных языков, которые оптимизируют скорость, а не простоту чтения кода, используют другую стратегию: вместо того, чтобы производить подстановку, они просто записывают информацию о связи между именем переменной и значением аргумента во вспомогательной структуре данных, называемой окружением (environment), и эта структура передается вместе с вычисляемым термом. При обращении к переменной мы ищем ее значение в текущем окружении. Такую стратегию можно смоделировать, если рассматривать окружение как своего рода явную подстановку (explicit substitution) — т. е., перевести механизм подстановки из метаязыка в объектный язык, сделать его частью синтаксиса термов, с которыми работает вычислитель, а не внешней операцией на термах. Явные подстановки появились в исследовании Абади, Карделли, Куриена и Леви (Abadi, Cardelli, Curien, and Lévy, 1991a) и стали с тех пор активной областью исследований.

Если вы что-то реализовали в программе, это еще не значит, что вы это поняли. Брайан Кантвелл Смит


1
В основном в этой главе обсуждается чистое бестиповое лямбда-исчисление (рис. 5.3). Соответствующая реализация называется untyped. Интерпретатор fulluntyped содержит расширения, например, поддержку чисел и булевских значений.

Part II
Простые типы

Chapter 8  Типизированные арифметические выражения

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

8.1  Типы

Напомним синтаксис арифметических выражений:

t::= термы:
  trueконстанта <<истина>>
  falseконстанта <<ложь>>
  if t then t else tусловное выражение
  0константа <<ноль>>
  succ tследующее число
  pred tпредыдущее число
  iszero tпроверка на ноль

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

v::=значения:
  trueконстанта <<истина>>
  falseконстанта <<ложь>>
  nvчисловое значение
    
nv::=числовые значения:
  0нулевое значение
  succ nvзначение-последователь

либо вычисление может на каком-то шаге зайти в тупик, наткнувшись на терм вроде pred false, к которому не применимы никакие правила.

Тупиковые термы соответствуют бессмысленным или ошибочным программам. Поэтому, мы хотели бы иметь возможность удостовериться в том, что вычисление данного терма наверняка не зайдет в тупик, не производя само вычисление. Для этого мы должны уметь отличать термы, значение которых будет числовым (поскольку только эти термы могут использоваться в качестве аргументов для pred, succ и iszero) от тех, значение которых будет булевским (только такие термы могут служить условием в условном выражении). Для такой классификации термов мы вводим два типа, Nat и Bool. На протяжении всей книги метапеременные S, T, U и т. п. будут обозначать типы.

Утверждение <<терм t имеет тип T>> (или <<t принадлежит типу T>>, или <<t является элементом T>>) означает, что t <<очевидным образом>> дает при вычислении значение нужного вида — при этом под словами <<очевидным образом>> мы подразумеваем, что проверить это можно статически (statically), не производя само вычисление t. Например, терм if true then false else true имеет тип Bool, а pred (succ (pred (succ 0))) имеет тип Nat. Однако наш анализ типов будет консервативным (conservative), то есть, будет обращаться только к статической информации. Это означает, что мы не сможем определить, имеют ли термы вроде if (iszero 0) then 0 else false, или даже if true then 0 else false какой-либо тип, даже при том, что их вычисление на самом деле в тупик не заходит.

8.2  Отношение типизации

Отношение типизации для арифметических выражений, записываемое в виде1 <<t : T>>, определяется набором правил вывода, присваивающих термам типы. Эти правила перечислены на рис. 8.1 и 8.2. Как и в главе 3, мы помещаем правила для булевских значений и для чисел на два разных рисунка, поскольку впоследствии нам иногда будет нужно ссылаться на них по отдельности.


B (типизированное) Расширяет B (3.1)



Новые синтаксические формы
T::= типы:
Bool тип булевских значений
Новые правила типизацииt : Tt : T
       
true : Bool
       
false : Bool
       
t1 : Bool           t2 : T           t3 : T
if t1 then t2 else t3 : T



Figure 8.1: Правила типизации для булевских значений (B).



B ℕ (типизированное) Расширяет NB (3.2) и 8.1





Новые синтаксические формы
T::= …типы:
Nat тип натуральных чисел

Новые правила типизацииt : Tt : T

       
0 : Nat
       
t1 : Nat
succ t1 : Nat
       
t1 : Nat
pred t1 : Nat
       
t1 : Nat
iszero t1 : Bool



Figure 8.2: Правила типизации для чисел (NB).


Правила T-True и T-False на рис. 8.1 присваивают булевским константам true и false тип Bool. Правило T-If присваивает тип условному выражению на основании типов его подвыражений: условие t1 должно при вычислении давать булевское значение, а t2 и t3 должны давать значения одного и того же типа. Два упоминания метапеременной T выражают ограничение, согласно которому тип результата вычисления if совпадает с типом ветвей then и else, причем этот тип может быть любым (либо Nat, либо Bool, а также, когда мы доберемся до исчислений с более интересными наборами типов, любой другой тип).

Правила для чисел на рис. 8.2 имеют аналогичный вид. T-Zero присваивает константе 0 тип Nat. T-Succ присваивает выражению succ t1 тип Nat при условии, что t1 имеет тип Nat. Аналогично, T-Pred и T-IsZero говорят, что pred дает в результате Nat, если его аргумент имеет тип Nat, а iszero дает результат типа Bool при аргументе типа Nat.

Определение 1   С формальной точки зрения, отношение типизации (typing relation) для арифметических выражений — это наименьшее бинарное отношение между термами и типами, удовлетворяющее всем правилам с рис. 8.1 и 8.2. Терм t является типизируемым (typable) (или корректно типизированным, well-typed), если существует тип T такой, что t : T.

В рассуждениях об отношениях типизации мы часто будем делать утверждения вроде <<Если терм вида succ t1 имеет какой-нибудь тип, то это должен быть тип Nat>>. Нижеследующая лемма дает нам набор утверждений такого вида. Каждое из них немедленно следует из формы соответствующего правила типизации.

41ex

Лемма 2   [Инверсия отношения типизации]:
  1. Если true : R, то R = Bool.
  2. Если false : R, то R = Bool.
  3. Если if t1 then t2 else t3 : R, то t1 : Bool, t2 : R и t3 : R.
  4. Если 0 : R, то R = Nat.
  5. Если succ t1 : R, то R = Nat и t1 : Nat.
  6. Если pred t1 : R, то R = Nat и t1 : Nat.
  7. Если iszero t1 : R, то R = Bool и t1 : Nat.
Доказательство: Непосредственно следует из определения отношения типизации.

Лемму об инверсии типизации часто называют леммой о порождении (generation lemma) для отношения типизации, поскольку она позволяет, имея верное утверждение о типе терма, понять, как можно породить доказательство этого утверждения. Лемма об инверсии непосредственно приводит к рекурсивному алгоритму, вычисляющему типы термов, поскольку для терма каждой синтаксической формы она показывает, как вычислить его тип (если таковой имеется), исходя из типов его подтермов. Мы подробно рассмотрим это утверждение в главе 9.

Упражнение 3   [★ ↛]: Докажите, что все подтермы типизируемого терма также типизируемы.

В §3.5 мы познакомились с понятием деревьев вывода для вычислений. Аналогичным образом, дерево вывода типов (typing derivation) представляет собой дерево, состоящее из экземпляров правил типизации. Каждой паре (t, T) в отношении типизации соответствует дерево вывода типов с заключением t : T. Вот, например, дерево вывода для утверждения типизации <<if iszero 0 then 0 else pred 0 : Nat>>:

 T-Zero
0 : Nat 
 T-IsZero
iszero 0 : Bool 
          
 T-Zero
0 : Nat 
          
 T-Zero
0 : Nat 
 T-Pred
pred 0 : Nat 
 T-If
if iszero 0 then 0 else pred 0 : Nat 

Иначе говоря, утверждения (statements) — это формальные высказывания о типах в программах, правила типизации (typing rules) — это импликации между утверждениями, а деревья вывода (typing derivations) — доказательства, построенные с помощью правил типизации.

Теорема 4   [Единственность типов]: Всякий терм t имеет не более одного типа. То есть, если t типизируем, то у него есть единственный тип. Более того, есть только один способ вывести этот тип с помощью правил рис. 8.1 и 8.2.

Доказательство: Прямолинейная структурная индукция по t. В каждом варианте используется соответствующее утверждение леммы инверсии и предположение индукции.

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

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

8.3  Безопасность = продвижение + сохранение

Основное свойство нашей (и любой другой) системы типов — безопасность (также называемая корректностью (soundness)): правильно типизированные термы <<никогда не ломаются>>. Мы уже договорились о формализации понятия <<поломки>> терма: это значит, что терм оказался в <<тупиковом состоянии>> (Определение 15), в котором терм не является окончательным значением, но правила вычисления ничего не говорят о том, что с ним делать дальше. Следовательно, мы хотим быть уверены в том, что правильно типизированные термы никогда не оказываются в тупике. Мы доказываем это в два шага, которые традиционно называют теоремами продвижения (progress) и сохранения (preservation).2

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

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

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

Для доказательства теоремы о продвижении удобно записать несколько утверждений о возможном виде канонических форм (canonical forms) типов Bool и Nat (т. е., правильно типизированных значениях этих типов).

Лемма 1   [Канонические формы]:
  1. Если v — значение типа Bool, то v равно либо true, либо false.
  2. Если v — значение типа Nat, то v является числовым значением согласно грамматике, представленной на рис. 3.2.

Доказательство: Часть (1): согласно грамматике, представленной на рис. 3.1 и 3.2, значения в этом языке могут иметь четыре формы: true, false, 0 и succ nv, где nv — числовое значение. Первые два варианта немедленно дают нам требуемый результат. Два оставшихся не могут возникнуть, поскольку v, по предположению, имеет тип Bool, а по пунктам 4 и 5 леммы об инверсии получается, что 0 и succ nv могут иметь только тип Nat, а не Bool. Часть (2) доказывается аналогично.

Теорема 2   [Продвижение]: Допустим, что t — корректно типизированный терм (то есть, t : T для некоторого типа T). Тогда либо t является значением, либо существует некоторый t, такой, что tt.

Доказательство: Индукция по дереву вывода t : T. Варианты T-True, T-False и T-Zero дают требуемый результат немедленно, так как в этих случаях t — значение. В остальных случаях мы рассуждаем так:

Вариант T-If:

t = if t1 then t2 else t3
t1 : Bool     t2 : T     t3 : T


Согласно предположению индукции, либо
t1 является значением, либо существует терм t1, такой что t1t1. Если t1 — значение, то, как следует из леммы о канонических формах, t1 является либо true, либо false, а следовательно, к t можно применить либо E-IfTrue, либо E-IfFalse. С другой стороны, если t1t1, то, по правилу E-If, tif t1 then t2 else t3.

Вариант T-Succ: t = succ t1     t1 : Nat
Согласно предположению индукции, либо
t1 является значением, либо существует какой-то t1, такой, что t1t1. Если t1 является значением, то, по лемме о канонических формах, это должно быть числовое значение, а тогда t — тоже значение. С другой стороны, если t1t1, то, по правилу E-Succ, succ t1succ t1.

Вариант T-Pred: t = pred t1     t1 : Nat
Согласно предположению индукции, либо
t1 является значением, либо существует какой-то t1, такой, что t1t1. Если t1 является значением, то, по лемме о канонических формах, это должно быть числовое значение, либо 0, либо succ nv1 для некоторого nv1, и тогда к t применимо одно из правил E-PredZero или E-PredSucc. Если, с другой стороны, t1t1, то, по правилу E-Pred, pred t1pred t1.

Вариант T-IsZero: t = iszero t1     t1 : Nat
Доказывается аналогичным образом.

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

Теорема 3   [Сохранение]: Если t : T и tt, то t : T.

Доказательство: Индукция по дереву вывода t : T. На каждом шаге индукции мы предполагаем, что требуемое свойство выполняется для всех поддеревьев (т. е., если s : S и ss, то s : S, когда s : S доказано в поддереве текущего вывода), и рассматриваем варианты последнего правила в выводе типа (мы демонстрируем лишь некоторые из вариантов; остальные устроены аналогично).

Вариант T-True: t = true     T = Bool
Если последнее правило вывода —
T-True, то из формы этого правила нам известно, что t — константа true, а T — тип Bool. Но в таком случае, t является значением, поэтому невозможно, чтобы для какого-либо t имелось tt, и требования теоремы просто не могут нарушаться.

Вариант T-If: t = if t1 then t2 else t3     t1 : Bool     t2 : T     t3 : T
Если последнее правило в выводе типа —
T-If, то из формы этого правила нам известно, что t имеет вид if t1 then t2 else t3 для некоторых t1, t2 и t3. Кроме того, должны существовать поддеревья вывода с заключениями t1 : Bool, t2 : T и t3 : T. При взгляде на правила вычисления, в которых левая сторона представляет собой условное выражение, мы видим три правила, которые могут привести к заключению tt: E-IfTrue, E-IfFalse и E-If. Рассмотрим их по отдельности (кроме E-IfFalse, которое аналогично E-IfTrue):

Подвариант E-IfTrue: t1 = true     t′ = t2
Если
tt выводится по правилу E-IfTrue, то из формы этого правила мы видим, что t1 должен равняться true, а результат t должен равняться второму подвыражению t2. Следовательно, требуемое утверждение доказано, так как нам известно (из предположений для варианта T-If), что t2 : T, чего мы и добиваемся.

Подвариант E-If: t1t1     t′ = if t1 then t2 else t3
Из предположений для варианта
T-If нам известно, что в исходном дереве вывода есть поддерево с заключением t1 : Bool. К этому поддереву мы можем применить предположение индукции и получить t1 : Bool. В сочетании с известными нам (из предположений для варианта T-If) утверждениями, что t2 : T и t3 : T, это дает нам право применить правило T-If и заключить, что if t1 then t2 else t3 : T, или, что то же самое, t : T.

Вариант T-Zero: t = 0     T = Nat
Невозможно (по тем же причинам, что и
T-True).

Вариант T-Succ: t = succ t1     T = Nat     t1 : Nat
Рассматривая правила вычисления на рис. 
3.2, мы видим, что есть только одно правило, E-Succ, пригодное для вывода tt. Из формы этого правила ясно, что t1t1. Поскольку мы знаем, что t1 : Nat, мы можем по предположению индукции заключить, что t1 : Nat, а отсюда, по правилу T-Succ, succ t1 : Nat, т. е., t : T.

Упражнение 4   [★★ ↛]: Перестройте доказательство так, чтобы индукция велась не по деревьям вывода типизации, а по деревьям вывода для отношения вычисления.

Теорему о сохранении часто называют теоремой о редукции субъекта (subject reduction) (или о вычислении субъекта, subject evaluation). Интуитивное представление здесь заключается в том, что утверждение о типизации t : T можно интерпретировать как предложение <<t имеет тип T>>. Терм t является подлежащим (субъектом) этого предложения, и свойство редукции субъекта говорит в таком случае, что истинность предложения сохраняется при редукции субъекта.

В отличие от единственности типов, которая присутствует в одних системах и отсутствует в других, продвижение и сохранение останутся важнейшими требованиями для всех систем типов, которые нам предстоит рассмотреть.4

Упражнение 5   [★]: Правило вычисления E-PredZero (рис. 3.2) не совсем согласуется с интуитивным представлением: кажется, что было бы естественней считать, что предшественник нуля не определен, а не определять его как ноль. Можно ли добиться этого, просто убрав правило E-PredZero из определения одношагового вычисления?
Упражнение 6   [Рекомендуется, ★★]: Рассмотрев свойство редукции субъекта, можно задуматься о том, насколько выполняется обратное свойство — расширение субъекта. Всегда ли, если tt и t : T, верно, что t : T? Если да, докажите это; если нет, приведите контрпример.
Упражнение 7   [Рекомендуется, ★★]: Допустим, что наше отношение вычисления определено в стиле с большим шагом, как в упражнении 17. Как в таком случае нужно формализовать интуитивное свойство безопасности типов?
Упражнение 8   [Рекомендуется, ★★]: Допустим, что наше отношение вычисления расширено так, чтобы бессмысленные термы сводились к явному состоянию wrong, как в упражнении 16. Как в этом случае формализовать безопасность типов?

Путь от бестиповых систем к типизированным был пройден многократно, в различных областях знания и, во многом, по одним и тем же причинам. Лука Карделли и Питер Вегнер (Cardelli and Wegner, 1985)


1
Система, изучаемая в этой главе — типизированное исчисление булевских значений и чисел (рис. 8.2). Соответствующая реализация на OCaml называется tyarith.
1
Часто вместо : используется символ ∈.
2
Лозунг <<безопасность — это продвижение плюс сохранение>> (с использованием леммы о канонических формах) был сформулирован Харпером; вариант того же лозунга был предложен Райтом и Феллейсеном (Wright and Felleisen, 1994).
3
В большинстве рассматриваемых нами систем вычисление сохраняет не только типизируемость, но и сам тип термов. Однако в некоторых системах тип при вычислении может меняться. Например, в системах с подтипами (глава 15) типы в процессе вычисления могут становиться меньше (более информативными).
4
Существуют языки, в которых эти свойства не выполняются, притом, что их все же можно считать безопасными с точки зрения типов. Например, при формализации операционной семантики Java в стиле с малым шагом (Flatt, Krishnamurthi, and Felleisen, 1998a, Igarashi, Pierce, and Wadler, 1999), сохранение типов в описанной здесь форме отсутствует (подробности см. в главе 19). Однако это обстоятельство следует рассматривать как особенность конкретного подхода к формализации, а не как дефект самого языка, поскольку она исчезает, например, при переходе к семантике с большим шагом.

Chapter 9  Простое типизированное лямбда-исчисление

В этой главе вводится самый простой представитель семейства типизированных исчислений, которые мы будем изучать в оставшейся части книги: простое типизированное лямбда-исчисление Чёрча (Church, 1940) и Карри (Curry and Feys, 1958).1

9.1  Типы функций

В главе 8 мы определили простую статическую систему типов для арифметических выражений с двумя типами: типом Bool, который относится к термам, вычисление которых дает булевские значения, и типом Nat, который относится к термам, при вычислении которых получаются числа. К <<плохо типизированным>> термам, не подпадающим ни под один из этих типов, относятся все термы, которые при вычислении попадают в тупиковое состояние (например, if 0 then 1 else 2), а также некоторые термы, которые на самом деле при вычислении ведут себя корректно, но для которых наша статическая классификация оказывается слишком жёсткой (скажем, if true then 0 else false).

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

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

if <долгое и сложное вычисление> then true else (λx.x)

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

61ex Ясно, что если мы хотим расширить систему типов с булевскими значениями и добавить к ней функции, то нам нужно ввести тип, относящийся к термам, вычисление которых дает в результате функцию. В качестве первого приближения назовем этот тип → (<<стрелка>>). Если мы введем правило типизации

λx.t :

дающее всякой λ-абстракции тип →, то мы можем классифицировать простые термы, например λx.x, а также сложные, например if true then (λx.true) else (λx.λy.y), как возвращающие при вычислении функцию.

Однако такой грубый анализ явно чересчур консервативен: например, функции λx.true и λx.λy.y попадают в одну группу и получают один и тот же тип. При этом игнорируется тот факт, что применение одной функции к аргументу дает булевское значение, а применение другой снова дает функцию. В общем случае, чтобы придать осмысленный тип результату применения, нужно не просто знать, что его левая часть — функция; нужно знать, какой именно тип эта функция возвращает. Более того, чтобы быть уверенными, что функция поведет себя правильно при вызове, нужно выяснить, аргументы какого типа она ожидает. Для сбора этой информации мы заменяем простой тип → бесконечным семейством типов вида T1 T2, каждый из которых описывает функции, которые принимают аргументы типа T1 и возвращают результаты типа T2.

Определение 1   Множество простых типов (simple types) на основе типа Bool порождается следующей грамматикой:
T::= типы:
  Boolтип булевских значений
  T Tтип функций

Конструктор типов (type constructor) право-ассоциативен, то есть выражение T1 T2 T3 обозначает T1 (T2 T3).

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

9.2  Отношение типизации

Чтобы определить тип абстракции вида λx.t, нужно понять, что случится, когда эта абстракция будет применена к какому-либо аргументу. Немедленно возникает вопрос: откуда мы знаем, аргументов какого типа следует ожидать? На этот вопрос возможны два ответа: либо мы просто помечаем λ-абстракцию типом ожидаемого аргумента, либо мы анализируем ее тело, смотрим, как используется в нем аргумент, и пытаемся определить, какого типа он должен быть. Сейчас мы выбираем первый вариант. Вместо выражения λx.t мы будем писать λx:T1.t2. Аннотация при связанной переменной говорит нам, что аргумент имеет тип T1.

Языки, в которых для помощи процедуре проверки типов используются аннотации на термах, называются явно типизированными (explicitly typed). Языки, где программа проверки типов пытается вывести (infer) или реконструировать (reconstruct) эту информацию, называются неявно типизированными (implicitly typed). (В литературе по λ-исчислению используется также термин системы присвоения типов, type-assignment systems.) По большей части в этой книге рассматриваются явно типизированные языки; неявной типизации посвящена глава 22.

Когда известен тип аргумента, ясно, что тип результата функции совпадает с типом тела t2, полученным исходя из предположения, что вхождения x внутри t2 обозначают термы типа T1. Эта интуитивная идея выражается следующим правилом типизации:

  
x:T1 ⊢ t2:T2
⊢ λx:T1.t2 : T1 T2

Поскольку термы могут содержать вложенные λ-абстракции, в общем случае нам придется говорить о нескольких подобных предположениях. Таким образом, отношение типизации из двухместного, t : T, становится трехместным, Γ ⊢ t : T, где Γ — набор предположений о типах свободных переменных в t.

Формально, контекст типизации (typing context) (также называемый окружение типизации, typing environment) Γ представляет собой последовательность переменных и их типов, а оператор <<запятая>> расширяет Γ, добавляя к нему справа новое связывание. Пустой контекст иногда изображается символом ∅, однако чаще всего мы просто опускаем его и пишем ⊢ t : T, что означает: <<Замкнутый терм t имеет тип T, исходя из пустого множества предположений>>.

Чтобы избежать конфликтов между новым связыванием и связываниями, уже присутствующими в Γ, мы требуем, чтобы имя x отличалось от переменных, связанных в Γ. Поскольку у нас действует соглашение, что переменные, связанные λ-абстракциями, разрешается переименовывать, это требование всегда можно выполнить, переименовав, если нужно, связанную переменную. Таким образом, всегда можно представлять Γ как конечную функцию, переводящую переменные в их типы. Согласно этому интуитивному представлению, множество переменных, присутствующих в Γ, можно обозначить выражением dom(Γ).

Правило для типизации абстракций в общем случае имеет вид:

  
Γ, x:T1 ⊢ t2:T2
Γ ⊢ λx:T1.t2 : T1 T2

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

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

  
x : T ∈ Γ
Γ ⊢ x : T

Предпосылка x:T ∈ Γ означает: <<Согласно Γ, предполагается, что x имеет тип T>>.

Наконец, нужно сформулировать правило типизации для применений.

  
Γ ⊢ t1 : T11 T12           Γ ⊢ t2 : T11
Γ ⊢ t1 t2 : T12

Если t1 дает при вычислении функцию, переводящую аргументы типа T11 в результаты типа T12 (исходя из предположения, что значения, обозначаемые его свободными переменными, имеют типы, заданные в Γ), и если t2 дает при вычислении результат типа T11, то результат применения t1 к аргументу t2 будет иметь тип T12.

Правила типизации для булевских констант и условных выражений остаются без изменений (рис. 8.1). Заметим, однако, что метапеременная T в правиле для условных выражений:

  
Γ ⊢ t1 : Bool           Γ ⊢ t2 : T           Γ ⊢ t3 : T
Γ ⊢ if t1 then t2 else t3 : T

теперь может замещаться любыми функциональными типами. Это позволяет типизировать условные выражения, ветви которых содержат функции:1

if true then (λx:Bool. x) else (λx:Bool. not x); |> (λx:Bool. x) : Bool -> Bool

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


(типизированное) Основано на λ(5.3)



Синтаксис
t::= термы:
x переменная
λ x:T.t абстракция
t t применение
 
v::= значения:
λ x:T.t значение-абстракция
 
T::= типы:
T T тип функций
 
Γ::= контексты:
пустой контекст
Γ,x:T связывание термовой переменной
Вычислениеtttt
        
t1 → t1
t1 t2 → t1 t2
          
t2 → t2
v1 t2 → v1 t2
          
(λ x:T11.t12) v2 → [x ↦ v2]t12

ТипизацияΓ ⊢ t : TΓ ⊢ t : T

        
x : T ∈ Γ
Γ ⊢ x : T
        
Γ, x:T1 ⊢ t2 : T2
Γ ⊢ λx:T1.t2 : T1 T2
        
Γ ⊢ t1 : T11 T12           Γ ⊢ t2 : T11
Γ ⊢ t1 t2 : T12



Figure 9.1: Чистое простое типизированное лямбда-исчисление (λ)


Часто для обозначения простого типизированного лямбда-исчисления мы будем использовать символ λ (мы будем использовать один символ для систем с разными наборами базовых типов).

Упражнение 1   [★]: На самом деле, чистое простое типизированное лямбда-исчисление без базовых типов является вырожденным (degenerate), в том смысле, что в нем нет ни одного корректно типизированного терма. Почему?

Из экземпляров правил типизации для λ можно конструировать деревья вывода (derivation trees), как уже было сделано для арифметических выражений. Например, вот вывод, показывающий, что терм (λx:Bool.x) true имеет тип Bool в пустом контексте.

x:Bool ∈ x:Bool T-Var
x:Bool ⊢ x:Bool 
 T-Abs
⊢ λx:Bool.x : Bool Bool 
          
 T-True
⊢ true : Bool 
 T-App
⊢ (λx:Bool.x) true : Bool 
Упражнение 2   [★ ↛]: Покажите (путем построения деревьев вывода), что следующие термы имеют заявленные типы:
  1. f:Bool Boolf (if false then true else false) : Bool
  2. f:Bool Boolλx:Bool. f (if x then false else x) : Bool Bool
Упражнение 3   [★]: Найдите контекст Γ, в котором терм f x y имеет тип Bool. Можете ли вы кратко описать множество всех таких контекстов?

9.3  Свойства типизации

Как и в главе 8, прежде, чем доказывать типовую безопасность, нужно установить несколько простых лемм. По большей части они похожи на те, что мы уже видели ранее — нужно просто добавить к отношению типизации контексты и в каждое доказательство ввести пункты, относящиеся к λ-абстракциям, применениям и переменным. Единственное действительно новое требование — это лемма о подстановке (substitution lemma) (8) для отношения типизации.

Прежде всего, лемма об инверсии (inversion lemma) содержит набор наблюдений о том, как строятся деревья вывода типов: пункт, относящийся к каждой синтаксической форме терма, говорит, что <<если терм данной формы корректно типизирован, то его подтермы должны иметь типы такой-то формы…>>.

Лемма 1   [Инверсия отношения типизации]:
  1. Если Γ ⊢ x : R, то x:R ∈ Γ.
  2. Если Γ ⊢ λx:T1.t2 : R, то R = T1 R2 для некоторого R2, и Γ, x:T1t2 : R2.
  3. Если Γ ⊢ t1 t2 : R, то существует некоторый тип T11, такой, что Γ ⊢ t1 : T11 R и Γ ⊢ t2 : T11.
  4. Если Γ ⊢ true : R, то R = Bool.
  5. Если Γ ⊢ false : R, то R = Bool.
  6. Если Γ ⊢ if t1 then t2 else t3 : R, то Γ ⊢ t1 : Bool и Γ ⊢ t2, t3 : R.

Доказательство: Лемма непосредственно следует из определения отношения типизации.

Упражнение 2   [Рекомендуется, ★★★]: Существуют ли такой контекст Γ и такой тип T, что Γ ⊢ x x : T? Если да, то приведите пример Γ и T, и постройте дерево вывода Γ ⊢ x x : T; если нет, то докажите это.

В §9.2 мы выбрали для нашего исчисления представление с явной типизацией, чтобы облегчить задачу проверки типов. При этом нам пришлось добавить аннотации типов — но только в определениях связанных переменных и нигде больше. В каком смысле этого <<достаточно>>? Один из ответов на этот вопрос дает теорема о единственности типов (uniqueness of types), которая утверждает, что правильно типизированные термы находятся в однозначном соответствии со своими деревьями вывода типов: по терму можно однозначно восстановить дерево его вывода (и, разумеется, наоборот). В сущности, соответствие настолько прямое, что нет почти никакой разницы между термом и его деревом вывода.

Теорема 3   [Единственность типов]: В любом заданном контексте типизации Γ терм t (в котором все свободные переменные лежат в области определения Γ) имеет не более одного типа. То есть, если терм типизируем, то тип у него только один. Более того, существует только одно дерево вывода на основе правил, порождающих отношение типизации.

Доказательство: Упражнение. Доказательство, в сущности, настолько прямолинейно, что почти не заслуживает изложения; однако выписывание всех деталей служит хорошей тренировкой по <<выстраиванию>> доказательств, касающихся отношения типизации.

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

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

Лемма 4   [Канонические формы]:
  1. Если v — значение типа Bool, то v — это либо true, либо false.
  2. Если v — значение типа T1 T2, то v = λx:T1.t2.

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

С помощью леммы о канонических формах мы можем доказать теорему о продвижении, аналогичную теореме 2. Утверждение теоремы нуждается в небольшой поправке: нас интересуют только замкнутые термы, без свободных переменных. Для открытых термов теорема неверна: терм f true является нормальной формой, но не значением. Однако этот пример не означает, что в языке имеется дефект, поскольку полные программы — термы, вычисление которых нас на самом деле интересует, — всегда замкнуты.

Теорема 5   [Продвижение]: Пусть t — замкнутый, правильно типизированный терм (т. е., для некоторого типа T, t : T). Тогда либо t является значением, либо имеется терм t, такой, что tt.

Доказательство: Прямолинейная индукция по деревьям вывода типов. Варианты с булевскими константами и условными выражениями точно повторяют доказательство теоремы о продвижении для типизированных арифметических выражений (2). Вариант с переменной не может возникнуть (поскольку t замкнут). Вариант с абстракцией следует непосредственно, поскольку абстракции — это значения.

Единственный интересный случай — это применение, в котором t = t1 t2, причем t1 : T11 T12 и t2 : T11. По предположению индукции, t1 либо является значением, либо может произвести шаг вычисления; то же самое верно и для t2. Если t1 может сделать шаг, то к t применимо правило E-App1. Если t1 — значение, а t2 может сделать шаг, то к t применимо правило E-App2. Наконец, если и t1, и t2 — значения, то, по лемме о канонических формах, t1 имеет вид λx:T11.t12, так что к t применимо правило E-AppAbs.

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

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

Лемма 6   [Перестановка]: Если Γ ⊢ t : T, и Δ представляет собой перестановку Γ, то Δ ⊢ t : T. Более того, глубина дерева вывода остается неизменной.

Доказательство: Несложная индукция по деревьям вывода типов.

Лемма 7   [Ослабление]: Если Γ ⊢ t : T, и xdom(Γ), то Γ, x:St : T. Более того, глубина дерева вывода остается неизменной.

Доказательство: Несложная индукция по деревьям вывода типов.

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

Лемма 8   [Сохранение типов при подстановке]: Если Γ, x:St : T и Γ ⊢ s : S, то Γ ⊢ [xs]t : T.

Доказательство: Индукция по глубине вывода утверждения Γ, x:St : T. Для каждого данного вывода мы рассматриваем варианты последнего используемого правила типизации.2 Наиболее интересные случаи — переменная и абстракция.

Вариант T-Var:

t = z
где z:T ∈ (Γ, x:S)


Требуется рассмотреть два подварианта, в зависимости от того, совпадает ли
z с x, или это разные переменные. Если z = x, то [xs]z = s. Требуемый результат тогда Γ ⊢ s : S, а это одна из предпосылок леммы. В противном случае, [xs]z = z, и результат следует непосредственно.

Вариант T-Abs:

t = λy:T2.t1
T = T2 T1
Γ,x:S, y:T2t1 : T1


По соглашению
4 можно считать, что xy и yFV(s). Согласно лемме о перестановке для одного из поддеревьев, получаем Γ,y:T2, x:St1 : T1. По лемме об ослаблении для другого поддерева (Γ ⊢ s : S) получаем Γ, y:T2s : S. Согласно предположению индукции, получается Γ, y:T2[xs]t1 : T1. По правилу T-Abs: Γ ⊢ λy:T2.[xs]t1 : T2 T1. Но это именно то утверждение, которое нам нужно, поскольку, по определению подстановки, [xs]t = λy:T2.[xs]t1.

Вариант T-App:

t = t1 t2
Γ,x:St1 : T2 T1
Γ,x:St2 : T2
T = T1


Согласно предположению индукции,
Γ ⊢ [xs]t1 : T2 T1 и Γ ⊢ [xs]t2 : T2. По правилу T-App, Γ ⊢ [xs]t1   [xs]t2 : T, т. е., Γ ⊢ [xs](t1   t2) : T.

Вариант T-True:

t = true
T = Bool


В этом случае
[xs]t = true, и требуемый результат Γ ⊢ [xs]t : T следует непосредственно.

Вариант T-False:

t = false
T = Bool


Аналогично.

Вариант T-If:

t = if t1 then t2 else t3
Γ, x:St1 : Bool
Γ, x:St2 : T
Γ, x:St3 : T


Три раза применяя предположение индукции, имеем

Γ ⊢ [xs]t1 : Bool
Γ ⊢ [xs]t2 : T
Γ ⊢ [xs]t3 : T,


и утверждение леммы следует по правилу
T-If.

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

Теорема 9   [Сохранение]: Если Γ ⊢ t : T и tt, то Γ ⊢ t : T.

Доказательство: Упражнение [Рекомендуется, ★★★]. Структура доказательства очень похожа на доказательство теоремы о сохранении для арифметических выражений (3), за исключением использования леммы о подстановке.

Упражнение 10   [Рекомендуется, ★★]: В упражнении 6 мы исследовали свойство расширения субъекта (subject expansion) для простого исчисления, работающего с типизированными арифметическими выражениями. Выполняется ли это свойство для <<функциональной части>> простого типизированного лямбда-исчисления? А именно: допустим, что t не содержит условных выражений. Можно ли, исходя из tt и Γ ⊢ t : T, сделать вывод Γ ⊢ t : T?

9.4  Соотношение Карри-Говарда

Конструктору типов <<→>> соответствуют два вида правил:

  1. правило введения (introduction rule) (T-Abs), показывающее, как элементы типа могут создаваться, и
  2. правило устранения (elimination rule) (T-App), показывающее, как элементы типа могут использоваться.

Если форма, которая вводит элемент типа (λ), является непосредственным подтермом формы, устраняющей его (применение), получается редекс — место, в котором может произойти вычисление.

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

Упражнение 1   [★]: Какие из правил для типа Bool на рис. 8.1 являются правилами введения, а какие — правилами устранения? А для правил для типа Nat на рис. 8.2?

Терминология <<правила введенияустранения>> происходит из соответствия между теорией типов и логикой, известного как соотношение Карри-Говарда (Curry-Howard correspondence) или изоморфизм Карри-Говарда (Curry-Howard isomorphism) (Curry and Feys, 1958, Howard, 1980). Вкратце, идея состоит в том, что в конструктивных логиках доказательство утверждения P состоит в демонстрации конкретного свидетельства (evidence) в пользу P.3 Карри и Говард заметили, что это свидетельство во многом похоже на вычисление. Например, доказательство утверждения PQ можно рассматривать как механическую процедуру, которая, получая доказательство P, строит доказательство Q — или, с другой точки зрения, доказательство Q, абстрагированное от доказательства P. Аналогично, доказательство PQ состоит из доказательства P в сочетании с доказательством Q. Такое наблюдение приводит к следующему соответствию:

ЛогикаЯзыки программирования
утверждениятипы
утверждение PQтип P Q
утверждение PQтип P × Q (см. §11.6)
доказательство утверждения Pтерм t типа P
утверждение P доказуемосуществуют термы типа P



С этой точки зрения, терм в простом типизированном лямбда-исчислении является доказательством логического утверждения, соответствующего его типу. Вычисление (редукция на лямбда-термах) соответствует логической операции упрощения доказательств методом устранения сечений (cut elimination). Соотношение Карри-Говарда называют также аналогией <<утверждения как типы>> (<<propositions as types>>). Его подробное обсуждение можно найти во многих источниках, в том числе в книге Жирара, Лафонта и Тейлора (Girard, Lafont, and Taylor, 1989), у Галье (Gallier, 1993), Сёренсена и Ужичина (Sørensen and Urzyczyn, 1998), Пфеннинга (Pfenning, 2001), Губо-Ларрека и Макки (Goubault-Larrecq and Mackie, 1997), а также у Симмонса (Simmons, 2000).

Красота соотношения Карри-Говарда заключается в том, что оно не ограничено какой-то одной системой типов и одной логикой — напротив, его можно распространить на широкий спектр систем типов и логик. Например, Система F (гл. 23), в которой параметрический полиморфизм связан с квантификацией по типам, в точности соответствует конструктивной логике второго порядка, в которой разрешена квантификация по утверждениям. Система Fω (гл. 30) соответствует логике высших порядков. Более того, это соответствие часто использовалось для переноса результатов из одной области в другую. Так, линейная логика (linear logic) Жирара (1987) приводит к идее систем линейных типов (linear type systems) (Wadler, 1990, Wadler, 1991, Turner, Wadler, and Mossin, 1995, Hodas, 1992, Mackie, 1994, Chirimar, Gunter, and Riecke, 1996, Kobayashi, Pierce, and Turner, 1996, и многие другие). Аналогично, модальные логики (modal logics) использовались при разработке систем частичного вычисления (partial evaluation) и порождения кода во время выполнения (run-time code generation) (см. Davies and Pfenning, 1996, Wickline, Lee, Pfenning, and Davies, 1998, и другие источники, цитируемые в указанных трудах).

9.5  Стирание типов и типизируемость

На рис. 9.1 мы определили отношение вычисления непосредственно на просто типизированных термах. Несмотря на то, что аннотации типов не играют при вычислении никакой роли — во время выполнения мы не проводим никаких проверок того, что функции применяются к аргументам подходящих типов, — мы сохраняем эти аннотации внутри вычисляемых термов.

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

Определение 1   Функция стирания просто типизированного терма t определяется так:
    erase (x)=x
    erase (λx:T1.t2)=λx. erase (t2)
    erase (t1 t2)=      erase (t1)   erase (t2)
  

Разумеется, мы ожидаем, что два способа представления семантики простого типизированного лямбда-исчисления совпадут: результат вычисления типизированного терма напрямую не должен отличаться от результата вычисления бестипового терма, получившегося в результате предварительного стирания типов. Это требование формализуется в следующей теореме, которая формализует идею того, что <<вычисление коммутирует со стиранием>>. Здесь имеется в виду, что эти операции можно проводить в любом порядке — вычислив, и потом стерев типы, мы получаем тот же самый результат, как если бы сначала стерли типы, а потом вычислили терм:

Теорема 2   
  1. Если tt согласно типизированному отношению вычисления, то erase (t) → erase (t).
  2. Если erase (t) → m ′ согласно бестиповому отношению вычисления, то существует типизированный терм t, такой, что tt и erase (t) = m ′.

Доказательство: Несложная индукция по деревьям вывода вычисления.

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

Еще один интересный вопрос, связанный с функцией стирания, таков: если у нас есть бестиповый терм m, можем ли мы найти простой типизированный терм t, дающий при стирании типов m?

Определение 3   Терм m бестипового лямбда-исчисления называется типизируемым (typable) в λ, если имеется простой типизированный терм t типа T и контекст Γ такой, что erase (t) = m и Γ ⊢ t : T.

К этому вопросу мы вернемся в главе 22, когда рассмотрим близкородственную проблематику реконструкции типов (type reconstruction) для λ.

9.6  Стиль Карри и стиль Чёрча

Как мы видели, семантику простого типизированного лямбда-исчисления можно определить двумя способами: как отношение вычисления, определенное прямо на синтаксисе простого типизированного исчисления, либо как компиляцию в бестиповое исчисление в сочетании с отношением вычисления на бестиповых термах. Важное сходство между этими двумя стилями состоит в том, что можно говорить о поведении терма t вне зависимости от того, корректно ли он типизирован. Такая форма определения языка называется стилем Карри (Curry-style). Сначала мы задаем грамматику термов, затем определяем их поведение, и, наконец, добавляем систему типов, отвергающую некоторые термы, поведение которых нам не нравится. Семантика возникает раньше, чем типизация.

Принципиально другой способ организации определения языка состоит в том, чтобы сначала определить термы, затем выбрать из них правильно типизированные термы, и, наконец, определить семантику только для них. В таких системах, которые называются системами в стиле Чёрча (Church-style), типизация идет прежде семантики: мы даже не задаём вопрос: <<каково поведение неверно типизированного терма?>>. В сущности, строго говоря, в системах, построенных по Чёрчу, мы вычисляем не термы, а деревья вывода типов для них. (Пример можно найти в §15.6.)

Исторически сложилось, что неявно типизированные представления лямбда-исчислений часто описывают в стиле Карри, а представления Чёрча в основном встречаются для явно типизированных систем. Отсюда происходит некоторая путаница в терминологии: иногда <<стилем Чёрча>> называют явно типизированный синтаксис, а <<стилем Карри>> — неявно типизированный.

9.7  Дополнительные замечания

Простое типизированное лямбда-исчисление изучается в книге Хиндли и Селдина (Hindley and Seldin, 1986) и, еще более подробно, в монографии Хиндли (Hindley, 1997).

Правильно типизированные программы не могут <<сломаться>>. Робин Милнер (Milner 1978)


1
В этой главе изучается простое типизированное лямбда-исчисление (рис. 9.1) с булевскими значениями (рис. 8.1). Соответствующая реализация на OCaml называется fullsimple.
1
Начиная с этого момента, в примерах простого взаимодействия с интерпретаторами обычно будут показаны не только результаты, но и их типы (иногда мы будем их пропускать, если они очевидны).
2
Или, что равносильно, варианты возможной формы t, поскольку для каждого синтаксического конструктора существует ровно одно правило типизации.
3
Характерное различие между классическими и конструктивными логиками состоит в том, что в последних отсутствует правило исключенного третьего (excluded middle), которое утверждает, что для всякого утверждения Q истинно либо само Q, либо ¬ Q. Чтобы доказать Q ∨ ¬ Q в конструктивной логике, требуется предоставить свидетельство либо в пользу Q, либо в пользу ¬ Q.

Chapter 10  Реализация простых типов на ML

Конкретная реализация λ в виде программы на ML следует той же схеме, что и реализация бестипового лямбда-исчисления в главе 7. Основное добавление — функция typeof для вычисления типа данного терма в данном контексте. Впрочем, прежде чем мы до нее доберемся, нам требуется написать несколько низкоуровневых процедур для работы с контекстами.1

10.1  Контексты

Напомним (глава 7, с. ??), что контекст (context) представляет собой просто список имен переменных и связываний:

type context = (string * binding) list

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

type binding = NameBind

При реализации процедуры проверки типов нам понадобится использовать контекст для хранения сведений о типах переменных. Ради этого мы добавляем к типу binding новый конструктор VarBind:

type binding = NameBind | VarBind of ty

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

Функция typeof вызывает функцию addbinding, чтобы добавить в контекст ctx новое связывание переменной (x, bind); поскольку контексты у нас представляются в виде списков, то addbinding, в сущности, работает просто как cons:

let addbinding ctx x bind = (x,bind)::ctx

Напротив, функция getTypeFromContext используется для поиска сведений о типе, связанном с некоторой переменной i в контексте ctx (информация fi о позиции в файле служит для распечатки сообщения об ошибке, когда i оказывается вне контекста):

let getTypeFromContext fi ctx i = match getbinding fi ctx i with VarBind(tyT) -> tyT | _ -> error fi ("getTypeFromContext: Wrong kind of binding for variable " ^ (index2name fi ctx i))

Оператор match производит проверку на внутреннюю непротиворечивость: в нормальных условиях getTypeFromContext должна всегда вызываться из контекста, где i-е связывание создано конструктором VarBind. Однако в последующих главах мы введем другие типы связываний (в частности, связывания для типовых переменных, type variables), и может случиться так, что getTypeFromContext будет вызвана с переменной неправильного вида. В этом случае она печатает сообщение об ошибке при помощи низкоуровневой функции error, передавая ей info, чтобы сообщить, в какой позиции в файле произошла ошибка.

val error : info -> string -> 'a

Тип результата функции error — типовая переменная ’a, которая может принимать значение любого типа ML (что имеет смысл, так как error все равно никогда не возвращается: она печатает ошибку и останавливает программу). В данном случае следует предположить, что результат error имеет тип ty, так как именно его возвращает другая ветвь match.

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

val getbinding : info -> context -> int -> binding

Ее определение можно найти в реализации simplebool на веб-сайте книги.

10.2  Термы и типы

Синтаксис типов прямо переводится из абстрактного синтаксиса, представленного на рис. 8.1 и 9.1, в определение типа языка ML.

type ty = TyBool | TyArr of ty * ty

Представление термов такое же, как было у нас при реализации бестипового лямбда-исчисления (с. ??), но с добавлением аннотации типа к варианту TmAbs.

type term = TmTrue of info | TmFalse of info | TmIf of info * term * term * term | TmVar of info * int * int | TmAbs of info * string * ty * term | TmApp of info * term * term

10.3  Проверка типов

Функцию проверки типов typeof можно рассматривать как простой перевод правил типизации для λ (рис. 8.1 и 9.1), или, точнее, как перевод леммы об инверсии (1). Второй подход точнее, поскольку именно лемма об инверсии определяет для каждой синтаксической формы, какие условия должны выполняться, чтобы терм считался правильно типизированным. Правила типизации утверждают, что термы некоторого вида правильно типизированы при некоторых условиях, но глядя на каждое конкретное правило типизации, никогда нельзя заключить, что некоторый терм не типизирован правильно, поскольку всегда остается возможность, что для типизации терма достаточно применить какое-то другое правило. (В данный момент эта разница может показаться несущественной, поскольку лемма об инверсии непосредственно следует из правил типизации. Однако это различие становится важным в последующих системах, в которых доказательство леммы об инверсии более трудоемко, чем в λ.)

let rec typeof ctx t = match t with TmTrue(fi) -> TyBool | TmFalse(fi) -> TyBool | TmIf(fi,t1,t2,t3) -> if (=) (typeof ctx t1) TyBool then let tyT2 = typeof ctx t2 in if (=) tyT2 (typeof ctx t3) then tyT2 else error fi "arms of conditional have different types" else error fi "guard of conditional not a boolean" | TmVar(fi,i,_) -> getTypeFromContext fi ctx i | TmAbs(fi,x,tyT1,t2) -> let ctx' = addbinding ctx x (VarBind(tyT1)) in let tyT2 = typeof ctx' t2 in TyArr(tyT1, tyT2) | TmApp(fi,t1,t2) -> let tyT1 = typeof ctx t1 in let tyT2 = typeof ctx t2 in (match tyT1 with TyArr(tyT11,tyT12) -> if (=) tyT2 tyT11 then tyT12 else error fi "parameter type mismatch" | _ -> error fi "arrow type expected")

Здесь полезно сделать несколько замечаний о языке OCaml. Во-первых, мы записываем OCaml-овскую операцию проверки на равенство = в скобках, потому что используем её в префиксной позиции, а не в нормальной инфиксной. Это делается для того, чтобы легче было сравнивать наш код с последующими версиями typeof, в которых операция сравнения типов будет более изощренной, чем простое сравнение. Во-вторых, операция сравнения проверяет структурное равенство составных значений, а не равенство указателей. А именно, выражение

let t = TmApp(t1,t2) in let t' = TmApp(t1,t2) in (=) t t'

всегда возвращает true, несмотря на то, что два экземпляра TmApp, именуемые переменными t и t’, порождаются в разное время и имеют разные адреса в памяти.


1
Реализация, описанная в этой главе, соответствует простому типизированному лямбда-исчислению (рис. 9.1) с булевскими значениями (8.1). Код из этой главы можно найти в репозитории под именем simplebool.

Chapter 11  Простые расширения

Простое типизированное лямбда-исчисление имеет достаточно сложную структуру, чтобы было интересно изучать его теорию, но оно не слишком похоже на нормальный язык программирования. В этой главе мы начинаем двигаться в сторону более привычных языков и добавляем к исчислению несколько привычных языковых конструкций, которые можно типизировать без особого труда. Важной темой на протяжении всей главы являются производные формы (derived forms).1

11.1  Базовые типы

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

Помимо типов Bool и Nat, мы иногда будем для оживления примеров использовать базовые типы String (строки, например "hello") и Float (числа с плавающей точкой, например 3.14159).

В теоретических целях часто бывает удобно абстрагироваться от свойств конкретных базовых типов и их операций, и вместо этого полагать, что язык снабжен некоторым множеством A неинтерпретируемых (uninterpreted), или неизвестных (unknown), базовых типов, для которых не определено вообще никаких элементарных операций. Этого можно достигнуть, попросту включив A в множество типов (с метапеременной A, которая принимает значения из A), как показано на рис. 11.1. Для базовых типов мы используем букву A, а не B, чтобы избежать путаницы с символом B, который означает присутствие булевских значений в данной системе. Можно считать, что буква A отсылает к атомарным (atomic) типам — это название также часто используют для базовых типов, поскольку с точки зрения системы типов, у них нет никакой внутренней структуры. В качестве имен базовых типов мы будем употреблять символы A, B, C и т. д. Заметим, что, как и ранее с именами переменных и именами типовых переменных, A используется и как имя базового типа, и как метапеременная, принимающая базовые типы в качестве значений. Что конкретно имеется в виду в каждом случае, ясно из контекста.

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

λx:A. x; |> <fun>: A -> A

есть функция тождества для элементов A, каковы бы они ни были. Аналогично,

λx:B. x; |> <fun>: B -> B

есть функция тождества для B, а

λf:A -> A, λx:A. f(f(x)); |> <fun> : (A -> A) -> A -> A

есть функция, дважды применяющая некоторую данную функцию f к аргументу x.


A Расширяет λ (9.1)



Новые синтаксические формы
T::= …типы:
A базовый тип



Figure 11.1: Неинтерпретируемые базовые типы


11.2  Единичный тип

Еще один полезный базовый тип, встречающийся в основном в языках семейства ML — единичный тип Unit, описанный на рис. 11.2. В отличие от неинтерпретируемых базовых типов из предыдущего раздела, этот тип интерпретируется наипростейшим образом: мы явно вводим единственный его элемент: термовую константу unit (пишется с маленькой буквы u), и правило типизации, превращающее unit в элемент Unit. Кроме того, мы добавляем unit к списку значений, могущих служить результатом вычисления: в сущности, unit — единственный возможный результат вычисления выражения типа Unit.


Unit Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
unit константа unit
 
v::= …значения:
unit константа unit
 
T::= …типы:
Unit единичный тип
Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T
         Γ ⊢ unit : Unit

Новые производные формы

       
           t1;t2
def
=
 
(λx:Unit.t2) t1
  где x ∉ FV(t2)
         



Figure 11.2: Единичный тип


Даже в чисто функциональном языке тип Unit не лишен некоторого интереса,2 однако в основном он применяется в языках с побочными эффектами, вроде присваиваний ссылочным ячейкам (к этой теме мы еще вернемся в главе 13). В таких языках нас часто интересует в выражении не результат вычисления, а его побочный эффект; для подобных выражений Unit служит подходящим типом результата.

Это применение Unit аналогично роли типа void в языках, подобных C и Java. Имя void наводит на мысль о пустом типе Bot (ср. §15.4), но используется он скорее как наш Unit.

11.3  Производные формы: последовательное исполнение и связывания-пустышки

В языках с побочными эффектами часто бывает полезно вычислить два выражения одно за другим. Конструкция последовательного исполнения (sequencing notation) t1; t2 означает: <<вычислить t1, игнорировать его (тривиальный) результат, затем вычислить t2>>.

Формализовать последовательное исполнение можно двумя способами. Во-первых, можно следовать той же стратегии, которую мы использовали для других синтаксических форм: добавить t1; t2 в качестве новой альтернативы к синтаксису термов, а затем ввести два правила вычисления

  
t1 → t1
t1; t2 → t1; t2
  v1;t2 → t2

и правило типизации

  
Γ ⊢ t1 : Unit           Γ ⊢ t2 : T2
Γ ⊢ t1 ; t2 : T2

и таким образом зафиксировать требуемое поведение оператора ; (<<точка с запятой>>).

Другой способ формализации последовательного исполнения состоит в том, чтобы рассматривать t1; t2 просто как сокращенную запись (abbreviation) для терма (λx:Unit.t2) t1, в которой для переменной x выбирается новое (fresh) имя — то есть, такое, которое не совпадает ни с одной из свободных переменных t2.

Интуитивно достаточно ясно, что с точки зрения программиста оба способа добавить в язык последовательное исполнение приводят к одному и тому же результату: высокоуровневые правила типизации и вычисления можно вывести (derive), если рассматривать t1; t2 как сокращение для (λx:Unit.t2) t1. Это соответствие можно доказать более формально, продемонстрировав, что и типизацию, и вычисление можно <<менять местами>> с раскрытием сокращения.

Теорема 1   [Последовательное исполнение как производная форма]: Обозначим простое типизированное лямбда-исчисление, дополненное типом Unit, конструкцией последовательного исполнения и правилами E-Seq, E-SeqNext и T-Seq, символом λE (где буква E взята из выражения external language, <<внешний язык>>). Обозначим простое типизированное лямбда-исчисление, куда добавлен только тип Unit, символом λI (internal language, <<внутренний язык>>). Пусть имеется e ∈ λE → λI, функция раскрытия сокращений (term elaboration function), которая переводит выражения с внешнего языка на внутренний, заменяя каждое вхождение конструкции t1 ; t2 на (λx:Unit.t2) t1 с новой переменной x. Тогда для каждого терма t языка λE имеем где отношения вычисления и типизации языков λE и λI помечены, соответственно, символами E и I.

Доказательство: каждое направление в обоих “тогда и только тогда” доказывается с помощью прямолинейной индукции по структуре t.

Теорема 1 оправдывает применение термина <<производная форма>> (derived form), поскольку она демонстрирует, что поведение конструкции последовательного исполнения с точки зрения вычисления и типизации может быть выведено из более базовых операций — абстракции и применения. Преимущество введения таких конструкций, как последовательное исполнение, в виде производных форм состоит в том, что мы таким образом расширяем внешний синтаксис (т. е., язык, реально используемый программистами при написании программ), но при этом избегаем усложнения внутреннего языка, для которого нужно доказывать такие теоремы, как теорему о типовой безопасности. Такой метод борьбы со сложностью описания языковых конструкций можно найти уже в <<Определении Algol 60>> (Naur et al., 1963), и он широко применяется во многих более современных описаниях языков, таких, как <<Определение Standard ML>> (Milner, Tofte, and Harper, 1990, Milner, Tofte, Harper, and MacQueen, 1997).

Часто производные формы вслед за Ландином называют синтаксическим сахаром (syntactic sugar). Замена производной формы ее низкоуровневым определением называется удалением сахара (desugaring).

Еще одна производная форма, которой мы часто будем пользоваться впоследствии — соглашение о <<связываниях-пустышках>> в конструкциях, связывающих переменные. Часто бывает нужно (например, в термах, создаваемых при удалении сахара из конструкций последовательного исполнения) записать лямбда-абстракцию как <<заглушку>>, в теле которой связываемая переменная нигде не встречается. В таких случаях бывает неудобно каждый раз придумывать новое имя; вместо этого мы будем заменять его связыванием-пустышкой (wildcard binder), которое выглядит как _ (<<подчерк>>). Таким образом, мы будем пользоваться записью λ_:S.t в качестве сокращения записи λx:S.t, где x — некоторая переменная, не встречающаяся в t.

Упражнение 2   [★]: Запишите правила вычисления и типизации для абстракций со связыванием-пустышкой и докажите, что их можно вывести из правил раскрытия сокращений, приведенных выше.

11.4  Приписывание типа

Еще одна простая конструкция, которая впоследствии часто будет нам полезна, — это явное приписывание (ascription) определенного типа определенному терму (т. е., в программе явно записывается утверждение, что данный терм имеет данный тип). Мы пишем <<t as T>>, имея в виду <<терм t, которому мы приписываем тип T>>. Правило типизации T-Ascribe для этой конструкции (см. рис. 11.3) просто-напросто проверяет, что T действительно является типом терма t. Правило вычисления E-Ascribe столь же очевидно: оно отбрасывает as-конструкцию, и t после этого вычисляется как обычно.


as Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
t as T приписывание типа

Новые правила вычисленияtttt

        v1 as Tv1 
        
t1 → t1
t1 as T → t1 as T
Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T
        
Γ ⊢ t1 : T
Γ ⊢ t1 as T : T



Figure 11.3: Приписывание типа


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

Еще одно использование явного приписывания типов — управление распечаткой (printing) сложных типов. Программы проверки типов, которые использованы для проверки примеров в этой книге, а также вспомогательные реализации на OCaml, имена которых начинаются с full (<<полный>>), обеспечивают простой механизм для введения сокращений вместо длинных выражений типов. (В остальных реализациях механизм сокращений опущен, чтобы интерпретаторы легче было читать и модифицировать.) Например, объявление

UU = Unit -> Unit;

превращает идентификатор UU в сокращение для типа Unit Unit, которое можно использовать в программе. Где бы мы ни встретили идентификатор UU, его следует понимать как Unit Unit. Например, можно написать

(λf:UU. f unit) (λx:Unit. x);

Во время проверки типов эти сокращения по необходимости раскрываются. С другой стороны, процедуры проверки типов пытаются по возможности эти определения свернуть. (А именно, каждый раз, проверяя подтерм, они смотрят, не совпадает ли его тип в точности с каким-нибудь из известных сокращений.) Обычно при этом получаются разумные результаты, но изредка нам требуется распечатывать тип как-то иначе: либо из-за того, что простая стратегия сопоставления с образцом не дает программе проверки типов использовать сокращение (скажем, в системах, где разрешается изменять порядок именованных полей типов записи, она не поймет, что {a:Bool, b:Nat} можно свободно заменять на {b:Nat, a:Bool}), либо по какой-нибудь другой причине. К примеру, в

λf:Unit -> Unit. f; |> <fun> : (Unit -> Unit) -> UU

сокращение UU используется в результате функции, но не используется в ее аргументе. Если нам хочется, чтобы тип обозначался как UU, нужно либо изменить аннотацию типа на выражении-абстракции

λf:UU. f; |> <fun> : UU -> UU

либо явно приписать тип всей абстракции:

(λf:Unit -> Unit. f) as UU -> UU; |> <fun> : UU -> UU

Когда процедура проверки типов обрабатывает приписывание типа t as T, она разворачивает каждое сокращение в T, проверяя, что t имеет тип T, однако затем возвращает в качестве типа выражения-приписывания сам T, в точности так, как он был записан. Использование приписываний типа для управления печатью типов — особенность именно интерпретаторов, используемых в этой книге. В полноценном языке программирования механизмы для сокращения и распечатки типов либо не нужны (например, в Java все типы представляются короткими именами — ср. в главе 19), либо намного глубже встроены в язык (как в OCaml — см. ???).

Наконец, приписывание типов можно использовать в качестве механизма абстракции (abstraction). Этот способ подробно обсуждается в §15.5. В системах, в которых один и тот же терм t может иметь несколько типов (скажем, в системах с подтипами), через приписывание можно <<спрятать>> некоторые из них, указав программе проверки, что t нужно рассматривать, как если бы он обладал меньшим набором типов. Кроме того, в §15.5 будет обсуждаться связь между явным приписыванием типов и их преобразованием (casting).

Упражнение 1   [Рекомендуется, ★★]: (1) Покажите, как приписывание типов можно представить в виде производной формы. Докажите, что <<официальные>> правила типизации и вычисления должным образом соответствуют вашему определению. (2) Предположим, что вместо пары правил вычисления E-Ascribe и E-Ascribe1, мы ввели бы <<энергичное>> правило
    t1 as T → t1 
немедленно отбрасывающее приписывание с каждого обнаруженного терма. Можно ли при этом по-прежнему считать приписывание типа производной формой?

11.5  Связывание let

При написании сложного выражения часто бывает полезно присвоить имена некоторым его подвыражениям (как для того, чтобы избежать повторений, так и для простоты чтения). В большинстве языков есть способы сделать это. Например, в ML запись let x = t1 in t2 означает <<вычислить выражение t1 и связать имя x с получившимся значением при вычислении t2>>.

Наша конструкция связывания через let (представленная на рис. 11.4), подобно ML, следует порядку вычисления с вызовом по значению. Это означает, что терм, связанный через let, должен быть полностью вычислен, прежде чем начнет вычисляться тело let-формы. Правило типизации T-Let указывает, что тип let-выражения можно получить, сначала определив тип связанного терма, расширив контекст связыванием с этим типом, и вычислив в этом расширенном контексте тип тела let, который и будет тогда типом всего let-выражения.


let Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
let x=t in t связывание let

Новые правила вычисленияtttt

         let x=v1 in t2 → [xv1] t2  
        
t1 → t1
let x=t1 in t2let x=t1 in t2
Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T
          
Γ ⊢ t1 : T1           Γ, x:T1 ⊢ t2 : T2
Γ ⊢ let x=t1 in t2 : T2



Figure 11.4: Связывание let


Упражнение 1   [Рекомендуется, ★★★]: Программа проверки типов letexercise (ее можно скачать на сайте книги) является неполной реализацией let-выражений: в ней имеются простые функции синтаксического анализа и распечатки, но в функциях eval1 и typeof нет вариантов для конструктора TmLet (на их месте находятся заглушки, которые срабатывают на любой терм, а при попытке исполнения завершают программу с сообщением об ошибке). Допишите программу.

Можно ли определить let в виде производной формы? Да, как показал Ландин; однако детали реализации несколько сложнее, чем для последовательного исполнения и приписывания типов. С наивной точки зрения ясно, что эффекта let-связывания можно достичь, сочетая абстракцию и применение:

let x=t1 in t2     
def
=
 
     (λx:T1.t2) t1

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

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

Γ ⊢ t1 : T1
         
Γ, x:T1 ⊢ t2 : T2
 T-Let
Γ ⊢ let x=t1 in t2 : T2 

переводится в дерево, включающее абстракцию и применение:

Γ, x:T1 ⊢ t2 : T2
 T-Abs
Γ ⊢ λx:T1.t2 : T1 T2 
         
Γ ⊢ t1 : T1
 
 T-App
Γ ⊢ (λx:T1.t2) t1 : T2 

Таким образом, let — <<несколько менее производная>> форма, чем уже виденные нами: ее поведение при вычислении можно выявить, удалив сахар, но ее поведение с точки зрения типизации должно быть встроено во внутренний язык.

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

Упражнение 2   [★★]: Можно попробовать удалять сахар в форме let путем ее немедленного <<вычисления>>, т. е. рассматривать let x=t1 in t2 как сокращение для [xt1]t2. Насколько хороша эта идея?

11.6  Пары

В большинстве языков программирования имеются различные способы построения составных структур данных. Простейшая из таких структур — пары (pairs), или, в более общем случае, кортежи (tuples) значений. В этом разделе мы рассматриваем пары, а более общие конструкции — кортежи и записи с метками полей — откладываем до §11.7 и §11.8.3

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


× Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
{t,t} пара
t.1 первая проекция
t.2 вторая проекция
 
v::= …значения:
{v,v} значение-пара
 
T::= …типы:
T1 × T2 тип-произведение

Новые правила вычисленияtttt

         {v1,v2}.1v1  
         {v1,v2}.2v2  
        
t1 → t1
t1.1 → t1.1
        
t1 → t1
t1.2 → t1.2
 
        
t1 → t1
{t1,t2} → {t1,t2}
 
        
t2 → t2
{v1,t2} → {v1,t2}
 

Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T

          
Γ ⊢ t1 : T1           Γ ⊢ t2 : T2 
Γ ⊢ {t1,t2} : T1 × T2
 
          
Γ ⊢ t1 : T11 × T12
Γ ⊢ t1.1 : T11
 
          
Γ ⊢ t1 : T11 × T12
Γ ⊢ t1.2 : T12
 



Figure 11.5: Пары


Чтобы добавить пары к простому типизированному лямбда-исчислению, требуются два новых вида термов: порождение пары, которое записывается в виде {t1,t2}, и проекция, которая записывается в виде t.1, если это первая проекция, и в виде t.2, если это вторая проекция. Также необходим новый конструктор типов T1 × T2, который называется произведением (product) (или, иногда, декартовым произведением, cartesian product) типов T1 и T2. Пары записываются в фигурных скобках,4 чтобы подчеркнуть их связь с записями из §11.8.

В правилах вычисления нужно указать, как ведут себя пары и их проекции. Правила E-PairBeta1 и E-PairBeta2 указывают, что при сочетании полностью вычисленной пары с первой или второй проекцией результатом является соответствующая компонента пары. E-Proj1 и E-Proj2 позволяют производить вычисления внутри проекций, если терм, из которого производится проекция, еще не полностью вычислен. E-Pair1 и E-Pair2 вычисляют части пары: сначала левую, а затем — когда слева окажется значение, — правую часть.

Порядок, возникающий из использования метапеременных v и t в этих правилах, обеспечивает для пар стратегию вычисления слева направо. Например, составной терм

{pred 4, if true then false else false}.1

вычисляется (только) так:

 {pred 4, if true then false else false}.1
{3, if true then false else false}.1
{3, false}.1
3


Кроме того, нужно добавить новый вариант к определению значений, чтобы указать, что {v1, v2} является значением. То, что компоненты пары-значения сами обязаны быть значениями, обеспечивает полное вычисление пары, передаваемой как аргумент функции, прежде, чем начнет выполняться тело функции. Например:

 (λx:Nat×Nat. x.2) {pred 4, pred 5}
(λx:Nat×Nat. x.2) {3, pred 5}
(λx:Nat×Nat. x.2) {3,4}
{3,4}.2
4

Правила типизации для пар и их проекций не представляют сложности. Правило введения T-Pair указывает, что {t1, t2} имеет тип T1 × T2, при условии, что t1 имеет тип T1, а t2 — тип T2. Соответственно, правила устранения T-Proj1 и T-Proj2 говорят, что если t1 имеет тип-произведение T11 × T12 (т. е., если этот терм даст в качестве значения пару), то типами проекций этой пары будут T11 и T12.

11.7  Кортежи

Понятие двухместного произведения типов из предыдущего раздела нетрудно расширить до n-местных произведений, называемых кортежами (tuples). Например, {1,2,true} — трехместный кортеж, содержащий два числа и булевское значение. Его тип записывается как {Nat,Nat,Bool}.

Единственная трудность при таком обобщении состоит в том, что требуется придумать способ записи, единообразно описывающий структуры произвольного размера; такие соглашения всегда представляют некоторую сложность из-за неизбежного компромисса между точностью и читаемостью. Мы используем обозначение {tii ∈ 1..n} для кортежа из n членов, от t1 до tn, и {Tii ∈ 1..n} для его типа. Заметим, что при этом n может равняться нулю; в таком случае, диапазон 1..n пуст, а {tii ∈ 1..n} равняется {}, пустому кортежу. Кроме того, обратите внимание на различие между простым значением вроде 5 и одноэлементным кортежем вроде {5}: единственная операция, которую можно произвести с последним — извлечение первой компоненты.

На рис. 11.6 дано формальное определение кортежей. Оно похоже на определение типов-произведений (рис. 11.5), но с обобщением каждого правила для пар на случай с n членами, причем каждая пара правил для первой и второй проекции превращается в единственное правило для произвольной проекции кортежа. Единственное правило, заслуживающее отдельного внимания — E-Tuple, сочетающее и обобщающее правила E-Pair1 и E-Pair2 на рис. 11.5. На естественном языке его смысл выражается так: если имеется кортеж, в котором все поля слева от поля j уже преобразованы в значения, то можно проделать один шаг вычисления этого поля, из tj в tj. Как и раньше, при помощи метапеременных мы обеспечиваем стратегию вычисления слева направо.


{} Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
{tii∈ 1..n} кортеж
t.i проекция
 
v::= …значения:
{vii∈ 1..n} кортеж-значение
 
T::= …типы:
{Tii∈ 1..n} тип-кортеж
 

Новые правила вычисленияtttt

        
{vii∈ 1..n}.j → vj
 
        
t1 → t1
t1.i → t1.i
 
        
tj → tj
{vii∈ 1..j-1, tj, tkkj+1..n}                             → {vii∈ 1..j-1, tj, tkkj+1..n}
 

Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T

        
для каждого i,  Γ ⊢ ti : Ti
Γ ⊢ {tii∈ 1..n} : {Tii∈ 1..n}
 
        
Γ ⊢ t1 : {Tii∈ 1..n}
Γ ⊢ t1.j : Tj
 



Figure 11.6: Кортежи


11.8  Записи

Переход от n-местных кортежей к записям с помеченными полями также не представляет труда. Мы просто снабжаем каждое поле ti меткой (label), выбираемой из некоторого заранее заданного множества L. Например, {x=5} и {partno=5524,cost=30.27} — записи-значения; их типы — {x:Nat} и {partno:Nat,cost:Float}. Мы требуем, чтобы все метки полей в каждом конкретном терме-записи или типе записи были различны.

Правила для записей сведены в таблицу на рис. 11.7. Единственное правило, достойное отдельного упоминания, — E-ProjRcd, в котором мы опираемся на не вполне формальное соглашение. Это правило следует понимать так: если есть запись {li=vii∈ 1..n} с меткой j-го поля lj, то {li=vii ∈ 1..n}.lj за один шаг переходит в j-ое значение поля, vj. От этого соглашения (и от аналогичного соглашения для кортежей в правиле E-ProjTuple) можно было бы избавиться, переформулировав правило в более явном виде; однако при этом мы слишком сильно проиграли бы в удобстве чтения.


{} Расширяет λ (9.1)





Новые синтаксические формы
t::= …термы:
{li=tii ∈ 1..n} запись
t.l проекция
 
v::= …значения:
{li=vii ∈ 1..n} запись-значение
 
T::= …типы:
{li:Tii ∈ 1..n} тип записей
 

Новые правила вычисленияtttt

         {li=vii∈ 1..n}.ljvj  
        
t1 → t1
t1.l → t1.l
 
        
tj → tj
{li=vii∈ 1..j-1, lj=tj, lk=tkkj+1..n}                             → {li=vii∈ 1..j-1, lj=tj, lk=tkkj+1..n}
 

Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T

        
для каждого i,  Γ ⊢ ti : Ti
Γ ⊢ {li=tii∈ 1..n} : {li:Tii∈ 1..n}
 
        
Γ ⊢ t1 : {li:Tii∈ 1..n}
Γ ⊢ t1.lj : Tj
 



Figure 11.7: Записи


Упражнение 1   [★ ↛]: Запишите, для сравнения, E-ProjRcd в более явном виде.

Обратите внимание, что в верхнем углу блока правил, определяющих кортежи и записи, стоит одна и та же <<метка конструкции>> {}. В самом деле, кортежи можно считать особым случаем записей, просто разрешив в качестве меток полей как натуральные числа, так и слова-идентификаторы. В случае, когда i-ое поле записи имеет метку i, мы эту метку опускаем. Например, {Bool,Nat,Bool} считается сокращенной записью для {1:Bool,2:Nat,3:Bool}. (Такое соглашение разрешает даже смешивать именованные поля с нумерованными, и писать {a:Bool,Nat,c:Bool} как сокращение {a:Bool,2:Nat,c:Bool}, однако это вряд ли будет полезно на практике.) Во многих языках кортежи и записи считаются разными типами из более практических соображений: они по-разному реализуются компилятором.

Языки программирования различаются тем, как они относятся к порядку полей в записях. Во многих языках порядок полей как в значениях-записях, так и в определениях их типов никак не влияет на значение — т. е., термы {partno=5524,cost=30.27} и {cost=30.27,partno=5524} означают одно и то же и имеют один и тот же тип, который записывается либо в виде {partno:Nat,cost:Float}, либо в виде {cost:Float,partno=Nat}. Мы приняли другое решение: в этом тексте {partno=5524,cost=30.27} и {cost=30.27,partno=5524} представляют собой разные значения, с типами {partno:Nat,cost:Float} и {cost:Float,partno=Nat}, соответственно. В главе 15 мы примем другую, менее строгую, точку зрения на порядок полей, и введем отношение подтипирования, в котором типы {partno:Nat,cost:Float} и {cost:Float,partno:Nat} будут эквивалентны (equivalent) — каждый из них будет подтипом другого, так что термы одного типа всегда можно будет использовать там, где ожидается значение другого. (При наличии подтипов выбор между неупорядоченными и упорядоченными записями серьезно влияет на производительность; это обсуждается далее, в §15.6. Если принять решение в пользу неупорядоченных записей, то выбор между вариантом, в котором записи считают неупорядоченными с самого начала, и вариантом, в котором поля упорядочены на низком уровне, но введены правила, позволяющие этот порядок игнорировать, становится делом вкуса. Мы выбрали второй вариант, чтобы получить возможность обсудить оба.)

Упражнение 2   [★★★]: В нашем описании записей операция проекции позволяет извлечь поля записи по одному. Многие высокоуровневые языки программирования обладают альтернативной синтаксической конструкцией сопоставления с образцом (pattern matching), при помощи которой все поля записи можно извлечь одновременно. Такая конструкция позволяет сильно сократить многие программы. Как правило, образцы могут вкладываться друг в друга, и таким образом можно легко извлекать данные из сложных вложенных структур данных.

Можно добавить простую разновидность сопоставления с образцом к бестиповому лямбда-исчислению, введя новую синтаксическую категорию образцов (patterns), а также новый вариант (для самой конструкции сопоставления) к синтаксису термов. (См. рис. 11.8.)


{} let p (бестиповое) Расширяет 11.7 и 11.4



Новые синтаксические формы
p::= x образец-переменная
{li=pii ∈ 1..n} образец для записи
 
t::= …термы:
let p=t in t связывание с образцом
 

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

          match (x,v) = [xv]
          
для каждого  i,     match (pivi) = σi
match ({li=pii∈ 1..n},{li=vii∈ 1..n})                             = σ1 ∘ ⋯ ∘ σn
 

Новые правила вычисленияtttt

          let p=v1 in t2 → match (p,v1) t2
          
t1 → t1
let p=t1 in t2let p=t1 in t2



Figure 11.8: (Бестиповые) образцы для записей


Правило вычисления для сопоставления с образцом является обобщенным случаем для правила связывания let с рис. 11.4. Оно опирается на вспомогательную <<сопоставляющую>> функцию, которая, принимая образец p и значение v, либо терпит неудачу (что означает, что значение v не соответствует образцу), либо выдает в качестве результата подстановку, переводящую переменные, содержащиеся в p, в соответствующие части v. Например, match({x, y}, {5, true}) порождает подстановку [x5, ytrue], сопоставление match(x, {5, true}) порождает [x{5, true}], а match({x}, {5, true}) терпит неудачу. Правило E-LetV с помощью match вычисляет подстановку для переменных в p.

Сама функция match определяется отдельным набором правил вывода. Правило M-Var утверждает, что сопоставление с переменной всегда завершается успешно, и возвращает подстановку, переводящую переменную в значение, с которым производится сопоставление. Правило M-Rcd говорит, что чтобы сопоставить образец-запись {li=pii ∈ 1..n} со значением-записью {li=vii ∈ 1..n} (одинаковой длины, с одинаковыми метками полей), нужно по отдельности сопоставить каждый подобразец pi с соответствующим подзначением vi, получая при этом подстановку σi, и построить окончательный результат в виде композиции всех таких подстановок. (Мы требуем, чтобы ни одна переменная не встречалась в образце более одного раза, так что композиция подстановок будет просто их объединением.)

Покажите, как к этой системе добавить типы.

  1. Введите правила типизации для новых конструкций (при этом можно вносить в синтаксис любые нужные изменения).
  2. Постройте схематическое доказательство теорем о сохранении и продвижении для всего полученного исчисления. (Строить полные доказательства необязательно; достаточно сформулировать требуемые леммы и расположить их в правильном порядке.)

11.9  Типы-суммы

Во многих программах требуется работать с разнородными (heterogeneous) наборами данных. К примеру, вершина в бинарном дереве может быть либо <<листом>>, либо внутренней вершиной с двумя дочерними; аналогично, ячейка списка может быть либо пустой (nil), либо cons-ячейкой, состоящей из головы и хвоста;5 вершина абстрактного синтаксического дерева внутри компилятора может соответствовать переменной, абстракции, применению функции и т. д. Механизм теории типов, поддерживающий программирование такого рода, называется типами-вариантами (variant types).

Прежде чем ввести варианты в общем случае (в §11.10), рассмотрим более простой случай бинарных типов-сумм (sum types). Тип-сумма описывает множество значений, выбираемых из ровно двух данных типов. Допустим, например, что у нас есть типы

PhysicalAddr = {firstlast:String, addr:String}; VirtualAddress = {name:String, email:String};

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

Addr = PhysicalAddr + VirtualAddress;

каждый из элементов которого представляет собой либо PhysicalAddr, либо VirtualAddress.

Элементы этого типа создаются путем постановки тегов (tagging) на элементы типов-компонент PhysicalAddr и VirtualAddress. Например, если pa имеет тип PhysicalAddr, то inl pa будет иметь тип Addr. (Имена тегов inl и inr становятся понятны, если представить их как функции:

inl: PhysicalAddr -> PhysicalAddr+VirtualAddress; inr: VirtualAddress -> PhysicalAddr+VirtualAddress;

производящие <<инъекцию>> (вложение) элементов PhysicalAddr или VirtualAddress в левое или правое подмножество типа-суммы Addr. Заметим, однако, что в нашем формальном описании они функциями не являются.)

В общем случае, элементы типа T1 + T2 состоят из элементов T1, снабженных тегом inl, и элементов T2, снабженных тегом inr.

Для использования типов-сумм мы вводим конструкцию case, позволяющую различать, принадлежит ли данное значение левому или правому подмножеству суммы. Например, имя из значения типа Addr можно извлечь так:

getName = λa:Addr. case a of inl x => x.firstlast | inr y => y.name;

Если в виде параметра a выступает PhysicalAddr с тегом inl, выражение case выполняет первую ветвь и связывает переменную x со значением типа PhysicalAddr; тело первой ветви извлекает из x поле firstlast и возвращает его. Аналогично, если a есть VirtualAddress с тегом inr, будет выбрана вторая ветвь и возвращено поле name в значении типа VirtualAddress. Таким образом, тип всей функции getName — AddrString.

Описанные нами конструкции формально представлены на рис. 11.9. Мы добавляем к синтаксису термов левую и правую инъекции, а также конструкцию case. К типам мы добавляем конструктор суммы. К правилам вычисления мы добавляем два варианта <<бета-редукции>> для конструкции case: один для случая, когда первый подтерм сведен к значению v0 с тегом inl, а второй — для значения v0 с тегом inr. В обоих случаях мы выбираем соответствующую ветвь и выполняем ее тело, подставляя v0 вместо связанной переменной. Остальные правила вычисления делают шаг в первом подтерме выражения case, либо внутри тегов inl или inr.


+ Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
inl t постановка тега (левого)
inr t постановка тега (правого)
case t of inl x=>t | inr x=>t case
 
v::= …значения:
inl v значение с тегом (левым)
inr v значение с тегом (правым)
 
T::= …типы:
T+T тип-сумма
 

Новые правила вычисленияtttt

[box=]align*case (inl v0)
of inl x1=>t1 | inr x2=>t2
    →[x_1 ↦v_0]t_1

[box=]align*case (inr v0)
of inl x1=>t1 | inr x2=>t2
    →[x_2 ↦v_0]t_2

       
t0 → t0
case t0 of inl x1=>t1 | inr x2=>t2                             →  case t0 of inl x1=>t1 | inr x2=>t2
 
       
t1 → t1
inl t1 → inl t1
 
       
t1 → t1
inr t1 → inr t1
 

Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T

       
Γ ⊢ t1 : T1
Γ ⊢ inl t1 : T1 + T2
 
       
Γ ⊢ t1 : T2
Γ ⊢ inr t1 : T1 + T2
 
       
Γ ⊢ t0 : T1 + T2                             Γ, x1:T1 ⊢ t1 : T           Γ, x2:T2 ⊢ t2 : T          
Γ ⊢ case t0 of inl x1=>t1 | inr x2=>t2 : T
 



Figure 11.9: Типы-суммы


Правила типизации для тегов просты: чтобы показать, что inl t1 имеет тип-сумму T1 + T2, достаточно показать, что t1 принадлежит первому слагаемому типу, T1, и сделать то же самое для inr. В случае конструкции case нужно убедиться в том, что первый подтерм имеет тип T1 + T2, а также что тела обеих ветвей t1 и t2 имеют один и тот же тип T, предполагая, что связанные переменные x1 и x2 имеют типы T1 и T2, соответственно; результат всего case-выражения имеет тип T. Следуя соглашениям из предыдущих определений, рис. 11.9 не утверждает явно, что область видимости переменных x1 и x2 ограничена телами ветвей t1 и t2, но это можно понять по тому, как расширяются контексты в правиле типизации T-Case.

Упражнение 1   [★★]: Обратите внимание на сходство между правилом типизации для case и правилом для if на рис. 8.1: if можно рассматривать как своего рода вырожденную форму case, в которой в ветви не передается никакая информация. Формализуйте это интуитивное представление, определив true, false и if в виде производных форм на основе типов-сумм и Unit.

Суммы и единственность типов


→ + Расширяет λ (11.9)



Новые синтаксические формы
t::= …термы:
inl t as T постановка тега (левого)
inr t as T постановка тега (правого)
 
v::= …значения:
inl v as T значение с тегом (левым)
inr v as T значение с тегом (правым)
 

Новые правила вычисленияtttt

[box=]align*case (inl v0 as T0)
of inl x1=>t1 | inr x2=>t2
    →[x_1 ↦v_0]t_1

[box=]align*case (inr v0 as T0)
of inl x1=>t1 | inr x2=>t2
    →[x_2 ↦v_0]t_2
       
t1 → t1
inl t1 as T2  →  inl t1 as T2
       
t1 → t1
inr t1 as T2 →  inr t1 as T2

Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T

         
Γ ⊢ t1 : T1
Γ ⊢ inl t1 as T1 + T2 : T1 + T2
         
Γ ⊢ t1 : T2
Γ ⊢ inr t1 as T1 + T2 : T1 + T2



Figure 11.10: Суммы (с единственностью типизации)


Большинство полезных свойств отношения типизации чистого λ (см. §9.3) сохраняются и в системе с типами-суммами. Однако, теряется одно важное свойство: теорема о единственности типов (3). Сложность возникает из-за конструкций постановки тегов inl и inr. Например, правило типизации T-Inl говорит, что зная, что t1 является элементом T1, мы можем вывести утверждение, что inl t1 является элементом T1 + T2, для любого типа T2. Например, можно вывести inl 5 : Nat+Nat и inl 5 : Nat+Bool (и бесконечное число других типов). Несоблюдение единственности типов означает, что невозможно построить алгоритм проверки типов путем <<считывания правил снизу вверх>>, как мы это делали для всех конструкций, рассмотренных до сих пор. Мы можем выбрать один из нескольких вариантов:

  1. Можно усложнить алгоритм проверки типов, чтобы он как-то <<догадывался>>, какое значение T2 требуется. Мы можем сохранять T2 неопределенным и попытаться впоследствии определить, какой из типов должен быть на его месте. Такие методы будут подробно рассмотрены, когда мы будем изучать реконструкцию типов (глава 22).
  2. Можно расширить язык типов и единообразно представлять все типы T2 одновременно. Этот вариант будет изучен при обсуждении подтипов (глава 15).
  3. Можно потребовать, чтобы программист обеспечивал явную аннотацию (annotation), указывая, какой тип T2 имеется в виду. Эта альтернатива самая простая — и, на самом деле, не столь непрактичная, как могло бы показаться, поскольку в полноценных языках явные аннотации часто можно <<прицепить>> к другим конструкциям и сделать почти невидимыми (мы вернемся к этому вопросу в следующем разделе). Пока что мы выбираем именно этот вариант.

На рис. 11.10 показаны требуемые расширения языка по отношению к рис. 11.9. Вместо простого inl t или inr t мы пишем inl t as T или inr t as T, где T обозначает полностью тот тип-сумму, к которому мы намереваемся отнести снабженный тегом элемент. Правила типизации T-Inl и T-Inr используют указанный тип-сумму как тип результата инъекции, предварительно проверяя, что образ терма действительно принадлежит соответствующему подмножеству суммы (чтобы не повторять в правилах T1 + T2 по многу раз, синтаксические правила позволяют при инъекции указывать любой тип как аннотацию. Правила типизации позаботятся о том, чтобы этот тип всегда был типом-суммой, при условии, что инъекция правильно типизируется.) Синтаксис аннотаций типов специально похож на конструкцию приписывания типов из §11.4: в сущности, эти аннотации можно рассматривать как синтаксически обязательное приписывание типов.

11.10  Варианты

Бинарные типы-суммы являются частным случаем типов-вариантов (variant type) с метками, подобно тому, как бинарные типы-произведения являются частным случаем записей с метками полей. Вместо T1+T2 мы будем писать <l1:T1, l2:T2>, где l1 и l2 — метки полей. Вместо inl t as T1+T2 будем писать <l1=t> as <l1:T1, l2:T2>. А вместо обозначений inl и inr в вариантах конструкции case мы будем использовать те же метки, что и в соответствующем типе-сумме. С такими обобщениями пример функции getName из предыдущего раздела будет выглядеть так:

Addr = <physical:PhysicalAddr, virtual:VirtualAddress>; a = <physical=pa> as Addr; |> a: Addr getName = λa:Addr. case a of <physical=x> => x.firstlast | <virtual=y> => y.name; |> getName: Addr -> String

Формальное определение типов-вариантов приводится на рис. 11.11. Обратите внимание, что, как и в записях в §11.8, порядок меток в вариантном типе считается существенным.


<> Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
<l=t> as T постановка тега
case t of <li=xi>tii ∈ 1..n> case
 
v::= …значения:
<l=v> as T значение с тегом
T::= …типы:
<li:Tii∈ 1..n> тип вариантов
 

Новые правила вычисленияtttt

[box=]align*case (<lj=vj> as T) of
  <li=xi>tii ∈ 1..n
  →[x_j ↦v_j]t_j

        
t0 → t0
case t0 of <li=xi>tii ∈ 1..n                             → case t0 of <li=xi>tii ∈ 1..n
 
        
ti → ti
<li=ti> as T<li=ti> as T
 

Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T

        
Γ ⊢ tj : Tj
Γ ⊢ <lj=tj> as <li:Tii ∈ 1..n> : <li:Tii ∈ 1..n>
 
        
Γ ⊢ t0 : <li:Tii ∈ 1..n>                             для каждого i:   Γ,xi:Ti ⊢ ti:T
Γ ⊢  case t0 of <li=xi> tii ∈ 1..n : T
 



Figure 11.11: Варианты


Необязательные значения

Одна из весьма полезных идиом с использованием вариантов — необязательные значения (optional values). Например, элементом типа

OptionalNat = <none:Unit, some:Nat>;

может быть либо тривиальное значение unit с тегом none, либо целое число с тегом some. Другими словами, тип OptionalNat изоморфен типу Nat, к которому добавлено выделенное значение none. Тип

Table = Nat -> OptionalNat;

представляет конечные отображения из чисел в числа: областью определения такого отображения служит множество аргументов, на которых результат имеет вид <some=n> для некоторого n. Пустая таблица

emptyTable = λn:Nat. <none=unit> as OptionalNat; |> emptyTable : Table

это константная функция, возвращающая none в ответ на любое входное значение. Конструктор

extendTable = λt:Table. λm:Nat. λv:Nat. λn:Nat. if equal n m then <some=v> as OptionalNat else t n; |> extendTable: Table -> Nat -> Nat -> Table

берет таблицу и добавляет в нее (или изменяет имеющуюся) ячейку, отображающую вход m в выход <some=v>. (Функция equal определена в решении упражнения 1 на с. ??.)

Результат, полученный поиском в таблице типа Table, можно использовать в выражении case. Например, если у нас есть таблица t, и мы хотим получить значение, соответствующее числу 5, мы можем написать

x = case t(5) of <none=u> => 999 | <some=v> => v;

выдавая 999 в качестве значения x по умолчанию на случай, если t не определена для значения 5.

Во многих языках имеется встроенная поддержка необязательных значений. Например, в OCaml есть предопределенный конструктор типов option, и многие функции в типичных программах на OCaml возвращают необязательные значения. Например, null в языках вроде C, C++ и Java, в сущности, также представляет собой замаскированное необязательное значение. В этих языках переменная типа T (в случае, если T — <<ссылочный тип>>, то есть его значения выделяются в куче) может содержать либо особое значение null, либо указатель на значение типа T. То есть, тип такой переменной на самом деле есть Ref(Option(T)), где Option(T)=<none:Unit,some:T>. Конструкция Ref подробно обсуждается в главе 13.

Перечисления

Два <<вырожденных случая>> типов-вариантов достаточно полезны, чтобы упомянуть их отдельно: типы перечислений и варианты с одним полем.

Тип-перечисление (enumeration) — это вариантный тип, в котором с каждым полем ассоциирован тип Unit. Например, тип, представляющий рабочие дни недели, можно определить так:

Weekday = <monday:Unit, tuesday:Unit, wednesday:Unit, thursday:Unit, friday:Unit>;

Элементами этого типа являются термы вроде <monday:Unit> as Weekday. Действительно, поскольку в типе Unit всего один элемент, тип Weekday имеет ровно пять значений, однозначно соответствующих дням недели. При помощи конструкции case можно определять вычисления с использованием перечислений.

nextBusinessDay = λw:Weekday. case w of <monday=x> => <tuesday=unit> as Weekday | <tuesday=x> => <wednesday=unit> as Weekday | <wednesday=x> => <thursday=unit> as Weekday | <thursday=x> => <friday=unit> as Weekday | <friday=x> => <monday=unit> as Weekday;

Разумеется, используемый нами конкретный синтаксис плохо приспособлен к написанию и чтению таких программ. Некоторые языки (начиная с Паскаля) обладают особым синтаксисом для объявления перечислений и работы с ними. Другие — например, ML, см. с. ??, — представляют перечисления в виде частного случая вариантных типов.

Варианты с одним полем

Еще один интересный частный случай представляют собой вариантные типы с единственной меткой l:

V = <l:T>;

На первый взгляд, такой тип не кажется особенно полезным: в конце концов, все элементы V находятся во взаимно однозначном соответствии с элементами типа поля T, поскольку всякий элемент V имеет в точности форму <l=t> для некоторого t : T. Существенно, однако, то, что стандартные операции над T неприменимы к элементам V без распаковки: невозможно случайно перепутать V и T.

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

dollars2euros = λd:Float. timesfloat d 1.1325; |> dollars2euros: Float -> Float euros2dollars = λe:Float. timesfloat e 0.883; |> euros2dollars: Float -> Float

(где функция timesfloat: Float -> Float -> Float служит для перемножения чисел с плавающей точкой). Если мы начнем с суммы в долларах,

mybankbalance = 39.50;

то ее можно перевести в евро и затем обратно в доллары так:

euros2dollars (dollars2euros mybankbalance) |> 39.49990125 : Float

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

dollars2euros (dollars2euros mybankbalance); |> 50.660971875 : Float

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

DollarAmount = <dollars:Float>; EuroAmount = <euros:Float>;

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

dollars2euros = λd:DollarAmount. case d of <dollars=x> => <euros = timesfloat x 1.1325> as EuroAmount; |> dollars2euros : DollarAmount -> EuroAmount euros2dollars = λe:EuroAmount. case e of <euros=x> => <dollars = timesfloat x 0.883> as DollarAmount; |> euros2dollars : EuroAmount -> DollarAmount

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

mybankbalance = <dollars=39.50> as DollarAmount; euros2dollars (dollars2euros mybankbalance); |> <dollars=39.49990125> as DollarAmount : EuroAmount

Более того, если написать бессмысленное двойное преобразование, то типы не сойдутся и программа будет отвергнута (как и должно быть):

dollars2euros (dollars2euros mybankbalance); |> Error: parameter type mismatch

Варианты и типы данных

Вариантный тип <li:Tii ∈ 1..n> приблизительно соответствует типу ML, который определяется так:7

type T = l1 of T1 | l2 of T2 | ... | ln of Tn

Однако стоит упомянуть несколько отличий.

  1. Имеется тривиальное, но способное вносить путаницу расхождение между нашими соглашениями по использованию прописных букв и соглашениями, принятыми в OCaml. В OCaml имена типов должны начинаться со строчной буквы, а имена конструкторов (в нашей терминологии, метки вариантов) — с прописной, так что, строго говоря, объявление типа должно записываться так:
    type t = L1 of t1 | ... | Ln of tn

    Чтобы не смешивать термы t и типы T, мы будем в этом обсуждении игнорировать соглашения языка OCaml и продолжим пользоваться нашими собственными.

  2. Самое интересное различие состоит в том, что OCaml не требует аннотации типа, когда элемент типа Ti с помощью конструктора li переводится в тип T: можно просто писать li(t). OCaml позволяет так делать, сохраняя при этом единственность типов, требуя, чтобы тип T был объявлен (be declared) перед использованием. Более того, метки вариантов T не должны использоваться никакими другими типами в той же области видимости. Таким образом, когда процедура проверки типов встречает терм li(t), она знает, что правильной аннотацией может быть только T. В сущности, аннотация <<спрятана>> внутри метки варианта.

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

  3. Еще одна хитрость OCaml заключается в том, если с вариантом связан тип данных Unit, то его можно не указывать. В результате перечисления можно определять так:

    type Weekday = monday | tuesday | wednesday | thursday |friday

    вместо такой записи:

    type Weekday = monday of Unit | tuesday of Unit | wednesday of Unit | thursday of Unit | friday of Unit

    Кроме того, метка варианта monday сама по себе (а не метка monday, примененная к вырожденному значению unit) считается значением типа Weekday.

  4. Наконец, типы данных OCaml увязывают вариантные типы с несколькими дополнительными конструкциями, которые мы исследуем по отдельности в последующих главах.

Варианты как непересекающиеся объединения

Типы-суммы и вариантные типы иногда называют непересекающимися объединениями (disjoint unions). Тип T1+T2 есть <<объединение>> T1 и T2 в том смысле, что элементы этого типа включают в себя все элементы T1 и T2. Это непересекающееся объединение, поскольку множества элементов T1 и T2 перед собственно объединением помечены тегами, соответственно, inl и inr. Таким образом, всегда можно легко узнать, происходит ли тот или иной элемент из T1 или из T2. Выражение тип-объединение (union type) также применяется к непомеченным (пересекающимся) типам объединений, которые описаны в §15.7.

Тип Dynamic

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

Один из хороших способов добиться этого заключается в добавлении типа Dynamic, значения которого представляют собой пары из значений v и меток типа T, где v имеет тип T. Элементы типа Dynamic строятся при помощи особой конструкции присваивания меток, а анализируются с помощью безопасной конструкции определения типа typecase. В сущности, можно рассматривать Dynamic как непересекающееся объединение бесконечного размера, в котором метками служат типы. См. работы Гордона (Gordon, circa 1980), Майкрофта (Mycroft, 1983), Абади, Карделли, Пирса и Плоткина (Abadi, Cardelli, Pierce, and Plotkin, 1991b), Леруа и Мони (Leroy and Mauny, 1991), Абади, Карделли, Пирса и Реми (Abadi, Cardelli, Pierce, and Rémy, 1995), а также Хенглейна (Henglein, 1994).

11.11  Рекурсия общего вида

Еще одна возможность, доступная во многих языках программирования — определение рекурсивных функций. Мы уже видели (глава 5, с. ??), что в бестиповом лямбда-исчислении такие функции можно определять с помощью комбинатора fix.

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

ff = λie:Nat -> Bool. λx:Nat. if iszero x then true else if iszero (pred x) then false else ie (pred (pred x)); |> ff : (Nat -> Bool) -> Nat -> Bool iseven = fix ff; |> iseven : Nat -> Bool iseven 7; |> false : Bool

Интуитивное представление здесь таково: функция высшего порядка ff, передаваемая аргументом в fix, является генератором (generator) для функции iseven: если ff применяется к функции ie, поведение которой приближенно соответствует требуемому поведению iseven вплоть до некоторого числа n (то есть, к функции, возвращающей правильные результаты, когда ее аргумент меньше или равен n), то она возвращает более точное приближение к iseven — функцию, выдающую правильные результаты вплоть до аргумента n+2. Применение fix к этому генератору возвращает его неподвижную точку — функцию, возвращающую правильный результат для любого входного значения n.

Есть, однако, одно важное отличие от бестипового языка: сама функция fix не может быть определена в рамках простого типизированного лямбда-исчисления. Более того, в главе 12 мы покажем, что никакое выражение, способное привести к незавершающемуся вычислению, не может быть типизировано исключительно средствами простых типов.8 Поэтому вместо того, чтобы определять fix как терм нашего языка, мы просто добавляем его в качестве нового примитива, вместе с правилами вычисления, имитирующими поведение бестипового комбинатора fix, и правилом типизации, отражающим его реальное использование. Эти правила изображены на рис. 11.12. (Сокращенная форма letrec будет описана далее.)


fix Расширяет λ (9.1)



Новые синтаксические формы
t::= …термы:
fix t неподвижная точка t
 

Новые правила вычисленияtttt

[box=]align*fix (λx:T1.t2)
→[x(fix (λx:T1.t2))]t_2

       
t1 → t1
fix t1 →  fix t1
 
Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T
       
Γ ⊢ t1 : T1 T1
Γ ⊢ fix t1 : T1
 

Новые производные формы

[box=]align*letrec x:T1=t1 in t2
=deflet x = fix (λx:T1.t1) in t2




Figure 11.12: Рекурсия общего вида


Простое типизированное лямбда-исчисление с добавлением чисел и комбинатора fix давно уже служит любимым объектом исследований для специалистов по языкам программирования, поскольку это простейший язык, в котором возникает множество тонких семантических явлений, например, полная абстракция (full abstraction) (Plotkin, 1977, Hyland and Ong, 2000, Abramsky, Jagadeesan, and Malacaria, 2000). Часто это исчисление называют PCF (<<Programming language for Computable Functions>>, <<Язык программирования вычислимых функций>>).

Упражнение 1   [★★]: Определите с помощью fix функции equal, plus, times и factorial.

Как правило, конструкция fix используется для построения функций (в качестве неподвижных точек функций, переводящих функции в функции), однако имеет смысл заметить, что тип T в правиле T-Fix не обязательно должен быть функциональным. Иногда эта дополнительная гибкость оказывается полезной. Например, она позволяет определить запись, состоящую из взаимно рекурсивных функций, как неподвижную точку функции, работающей с записями (содержащими функции). Следующая реализация iseven использует вспомогательную функцию isodd; эти две функции определены как поля в записи, причем определение этой записи абстрагируется от записи ieio, и ее компоненты используются для рекурсивных вызовов из тел полей iseven и isodd.

ff = λieio:{iseven:Nat -> Bool, isodd:Nat -> Bool}. {iseven = λx:Nat. if iszero x then true else ieio.isodd (pred x), isodd = λx:Nat. if iszero x then false else ieio.iseven (pred x)}; |> ff : {iseven:Nat -> Bool, isodd:Nat -> Bool} -> {iseven:Nat -> Bool, isodd:Nat -> Bool}

Неподвижная точка функции ff — это запись из двух функций,

r = fix ff; |> r : {iseven:Nat -> Bool, isodd:Nat -> Bool}

и обращение к первой из них дает нам саму функцию iseven:

iseven = r.iseven; |> iseven : Nat -> Bool iseven 7; |> false : Bool

Возможность создания неподвижной точки функции типа TT для любого T приводит к некоторым неожиданным последствиям. В частности, оказывается, что каждый тип имеет хотя бы один терм этого типа. Чтобы показать это, заметим, что для каждого типа T мы можем определить функцию divergeT:

divergeT = λ_:Unit. fix (λx:T.x); |> divergeT : Unit -> T

Каждый раз, когда divergeT применяется к аргументу unit, мы получаем бесконечную последовательность вычислений, в которой снова и снова применяется правило E-FixBeta, всегда давая один и тот же терм. Получается, что для каждого типа T терм divergeT unit служит неопределенным элементом (undefined element).

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

letrec iseven : Nat -> Bool = λx:Nat. if iszero x then true else if iszero (pred x) then false else iseven (pred (pred x)) in iseven 7; |> false : Bool

Конструкцию рекурсивного связывания letrec несложно определить в качестве производной формы:

letrec x:T1=t1 in t2    
def
=
 
    let x = fix (λx:T1.t1) in t2
Упражнение 2   [★]: Перепишите свои определения функций plus, times и factorial из упражнения 1 через letrec вместо fix.

Более подробную информацию об операторах неподвижной точки можно найти у Клопа (Klop, 1980) и Винскеля (Winskel, 1993).

11.12  Списки

Рассмотренные нами конструкции можно разделить на базовые типы (base types), такие как Bool и Unit, и конструкторы типов (type constructors), например, → и ×, которые строят новые типы на основе старых. Еще один полезный конструктор — List. Для каждого типа T, тип List T описывает списки конечной длины, состоящие из элементов типа T.

На рис. 11.13 показаны синтаксис, семантика и правила типизации для списков. С точностью до синтаксических расхождений (List T вместо T list и т. п.), а также того, что в нашем варианте все синтаксические формы требуют явной аннотации типов, наши списки, по существу, совпадают со списками, имеющимися в ML и других функциональных языках. Пустой список (с элементами типа T) записывается в виде nil[T]. Список, порождаемый при добавлении нового элемента t1 (типа T) в начало списка t2, записывается в виде cons[T] t1 t2. Голова и хвост списка t записываются как head[T] t и tail[T] t. Булевский предикат isnil[T] t выдает true тогда и только тогда, когда список t пуст.9


→ B List Расширяет λ (9.1) с булевскими значениями (8.1)



Новые синтаксические формы
t::= …термы:
nil[T] пустой список
cons[T] t t конструктор списка
isnil[T] t проверка на пустой список
head[T] t голова списка
tail[T] t хвост списка
 
v::= …значения:
nil[T] пустой список
cons[T] v v конструктор списка
 
T::= …типы:
List T тип списков
 

Новые правила вычисленияtttt

       
t1 → t1
cons[T] t1 t2cons[T] t1 t2
 
       
t2 → t2
cons[T] v1 t2cons[T] v1 t2
 
       
isnil[S] (nil[T])true
 
       
isnil[S] (cons[T] v1 v2)false
 
       
t1 → t1
isnil[T] t1isnil[T] t1
 
       
head[S] (cons[T] v1 v2)v1
 
       
t1 → t1
head[T] t1head[T] t1
 
       
tail[S] (cons[T] v1 v2)v2
 
       
t1 → t1
tail[T] t1tail[T] t1
 

Новые правила типизацииΓ ⊢ t : TΓ ⊢ t : T

       
Γ ⊢ nil[T1] : List T1
 
       
Γ ⊢ t1 : T1           Γ ⊢ t2 : List T1
Γ ⊢ cons[T1] t1 t2 : List T1
 
       
Γ ⊢ t1 : List T11
Γ ⊢ isnil[T11] t1 : Bool
 
       
Γ ⊢ t1 : List T11
Γ ⊢ head[T11] t1 : T11
 
       
Γ ⊢ t1 : List T11
Γ ⊢ tail[T11] t1 : List T11
 



Figure 11.13: Списки


Упражнение 1   [★★★]: Покажите, что в простом типизированном лямбда-исчислении с булевскими значениями и списками выполняются теоремы о продвижении и сохранении.
Упражнение 2   [★★]: Представленный здесь синтаксис списков включает множество аннотаций, которые на самом деле не нужны, поскольку правила типизации легко могли бы вывести нужный тип из контекста. (Эти аннотации предназначены для облегчения сравнения с кодированием списков в §23.4). Можно ли избавиться от всех аннотаций?

1
В этой главе рассматриваются различные расширения чистого типизированного лямбда-исчисления (рис. 9.1). Соответствующая реализация на OCaml, fullsimple, содержит все эти расширения.
1
Начиная с этого места, при записи результатов вычислений мы будем для экономии места заменять тела λ-абстракций на запись <fun>.
2
Мы надеемся, что читателю доставит удовольствие следующая небольшая загадка:
Упражнение 1   [★★★]: Есть ли способ построить последовательность термов t1, t2, …на языке простого типизированного лямбда-исчисления с Unit в качестве единственного базового типа так, чтобы для каждого n терм tn имел размер не более O(n), но для достижения нормальной формы требовал не менее O(2n) шагов вычисления?
3
В реализации fullsimple синтаксис пар, приводимый здесь, не поддерживается, поскольку кортежи все равно являются более общим решением.
4
Способ записи с фигурными скобками не вполне удачен для пар и кортежей, поскольку напоминает стандартное математическое обозначение множеств. Чаще, как в популярных языках вроде ML, так и в исследовательской литературе, используются круглые скобки. Встречаются также и другие виды нотации, например, квадратные или угловые скобки.
5
Эти примеры, подобно большинству случаев реального использования вариантных типов, используют также рекурсивные типы (recursive types) — хвост списка сам является списком, и т. д. К рекурсивным типам мы вернемся в главе 20.
6
Реализация fullsimple на самом деле не поддерживает описанные здесь конструкции с бинарными суммами — вместо этого есть более общая конструкция с вариантами, описанная ниже.
7
В этом разделе используется конкретный синтаксис типов данных OCaml, чтобы соответствовать тем главам книги, в которых обсуждается реализация интерпретаторов. Однако такие типы возникли в ранних диалектах ML, и их примерно в той же форме можно найти в Standard ML и в родственных языках вроде Haskell. Типы данных и сопоставление с образцом — вероятно, самые большие преимущества этих языков в ежедневной работе.
8
В дальнейшем, в главе 13 и главе 20, мы введем в систему простых типов некоторые расширения, возвращающие способность определить fix средствами самой системы.
9
Мы приняли <<представление списков через head/tail/isnil>> ради простоты. С точки зрения проектирования языков, вероятно, правильнее было бы рассматривать списки как тип данных и обращаться к их компонентам через выражения case, поскольку при этом большее число программистских ошибок становятся ошибками типизации.

Chapter 12  Нормализация

В этой главе мы рассматриваем еще одно фундаментальное теоретическое свойство чистого простого типизированного лямбда-исчисления: то, что вычисление правильно типизированной программы гарантированно останавливается за конечное число шагов — т. е., каждый правильно типизированный терм нормализуем (normalizable).1

Полноценные языки программирования не обладают свойством нормализации, в отличие от других свойств типовой безопасности, рассмотренных нами ранее. Это потому, что такие языки почти всегда расширяют простое типизированное лямбда-исчисление конструкциями вроде рекурсии общего вида (§11.11) или рекурсивных типов (гл. 20), с помощью которых можно создавать зацикливающиеся программы. Однако понятие нормализации снова возникнет, уже на уровне типов, когда в §30.3 мы будем обсуждать метатеорию Системы Fω: в этой системе язык типов, по существу, дублирует простое типизированное лямбда-исчисление, и завершимость алгоритма проверки типов полагается на то, что операция <<нормализации>> на выражениях, составленных из типов, гарантированно завершается.

Еще один повод изучать доказательства нормализации заключается в том, что это едва ли не самые изящные и восхитительные математические конструкции во всей литературе по теории типов. Зачастую (как в данном случае), они используют базовый метод доказательств через логические отношения (logical relations).

Некоторым читателям будет проще пропустить данную главу при первом чтении; в последующих главах никаких сложностей из-за этого не возникнет. (Полная таблица зависимостей между главами приведена на с. ??.)

12.1  Нормализация для простых типов

Рассматриваемый здесь язык — простое типизированное лямбда-исчисление с единственным базовым типом A. Доказательство нормализации для этого исчисления не вполне тривиально, поскольку при каждой редукции терма в подтермах могут создаваться множественные редексы.

Упражнение 1   [★]: Где именно терпит неудачу попытка доказать нормализацию при помощи простой индукции по размеру правильно типизированного терма?

Основная сложность здесь (как и во многих других доказательствах по индукции) в том, чтобы найти достаточно сильное предположение индукции. С этой целью определим для каждого типа T множество RT замкнутых термов типа T. Рассматривая эти множества как предикаты, введем обозначение RT(t) для tRT.1

Определение 2    

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

Форма определения 2 характерна для метода доказательств через логические отношения (logical relations). (Поскольку сейчас мы работаем только с одноместными отношениями, правильнее было бы сказать логические предикаты, logical predicates.) Если мы хотим доказать, что некоторое свойство P выполняется для всех замкнутых термов типа A, мы с помощью индукции на типах доказываем, что все термы типа A обладают свойством P, что все термы типа AA сохраняют свойство P, что все термы типа (AA) (AA) сохраняют свойство сохранения свойства P, и так далее. Мы добиваемся этого, определяя семейство предикатов, проиндексированных типами. Для базового типа A этот предикат — просто P. Для функциональных типов предикат утверждает, что функция должна переводить значения, удовлетворяющие предикату для входного типа, в значения, удовлетворяющие предикату для типа-результата.

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

Первый шаг непосредственно следует из определения RT:

Лемма 3   Если верно RT(t), то t завершается.

Второй шаг разбит на две леммы. Заметим сначала, что принадлежность множеству RT сохраняется при вычислении.

Лемма 4   Если t : T и tt, то RT(t) тогда и только тогда, когда RT(t′).

Доказательство: Индукция по структуре типа T. Во-первых, очевидно, что t завершается тогда и только тогда, когда завершается t. Если T = A, доказывать больше нечего. С другой стороны, предположим, что T = T1 T2 для некоторых T1 и T2. Для направления <<только тогда>> () предположим, что RT(t), и что RT1(s) для некоторого произвольного s : T1. По определению, имеем RT2(t s). Однако t st s, и отсюда, по предположению индукции для типа T2, получаем RT2(t s). Поскольку это верно для любого s, определение RT дает нам RT(t′). Рассуждение в направлении <<тогда>> () проводится аналогично.

Теперь нам нужно показать, что каждый терм типа T принадлежит множеству RT. Здесь индукция будет проводиться по деревьям вывода типов (было бы странно, если бы какое-либо доказательство, связанное с правильно типизированными термами, не содержало индукции по деревьям вывода типов!). Единственная сложность здесь заключается в обработке случая с λ-абстракцией. Поскольку мы проводим индукцию, доказательство того, что терм λx:T1.t2 принадлежит множеству RT1 T2, должно использовать предположение индукции, чтобы показать, что t2 принадлежит множеству RT2. Однако RT2 определяется как множество замкнутых термов, в то время как переменная x может быть свободна в t2, так что этот способ доказательства не работает.

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

Лемма 5   Если x1:T1, …, xn:Tnt : T, а v1, …, vn — замкнутые значения типов T1, …, Tn с RTi (vi) для каждого i, то RT([x1v1] ⋯ [xnvn]t).

Доказательство: Индукция по дереву вывода утверждения x1:T1, …, xn:Tnt : T. (Наиболее интересен вариант с абстракцией.)

Вариант T-Var:  t=xi   T=Ti
Утверждение леммы следует немедленно.

Вариант T-Abs:

t = λx:S1.s2   x1:T1, …, xn:Tn, x:S1s2 : S2
T = S1 S2


Очевидно,
[x1v1] ⋯ [xnvn]t дает при вычислении значение, поскольку уже является значением. Остается показать, что RS2(([x1v1] ⋯ [xnvn]t) s) для всякого терма s : S1 такого, что RS1(s). Предположим, что s — такой терм. Согласно лемме 3, имеем s* v для некоторого v. Согласно лемме 4, RS1(v). По предположению индукции, RS2 ([x1v1] ⋯ [xnvn] [xv] s2). Однако

 (λx:S1. [x1v1] ⋯ [xnvn]s2) s
      →*[x1 ↦ v1] ⋯ [xn ↦ vn]  [x ↦ vs2,

а отсюда, по лемме 4,

RS2 ((λx:S1. [x1v1] ⋯ [xnvn]s2) s),

то есть, RS2 ([x1v1] ⋯ [xnvn] (λx:S1. s2) s). Поскольку s был выбран произвольно, по определению RS1S2, имеем

RS1S2( [x1 ↦ v1] ⋯ [xn ↦ vn(λx:S1.s2)).


Вариант T-App:

t = t1 t2
x1:T1, … xn:Tnt1 : T11T12
x1:T1, … xn:Tnt2 : T11
T = T12


Предположение индукции дает нам
RT11T12 ( [x1v1] ⋯ [xnvn] t1) и RT11 ( [x1v1] ⋯ [xnvn] t2). По определению RT11T12,

RT12(([x1v1] ⋯ [xnvn]t1) ([x1v1] ⋯ [xnvn]t2))

т. е.,

RT12([x1 ↦ v1] ⋯  [xn ↦ vn] (t1 t2))

Теперь свойство нормализации является простым следствием, если считать, что терм t в лемме 5 замкнут, а затем использовать то обстоятельство, что все элементы RT нормализующие для всякого T.

Теорема 6   [Нормализация]: Если t : T, то t нормализуем.

Доказательство: RT(t) по лемме 5; следовательно, t нормализуем по лемме 3.

Упражнение 7   [Рекомендуется, ★★★]: С помощью методов этой главы покажите, что простое типизированное лямбда-исчисление сохраняет свойство нормализации, будучи расширено булевскими значениями (рис. 3.1) и типами-произведениями (рис. 11.5).

12.2  Дополнительные замечания

В теоретической литературе свойство нормализации чаще всего формулируется как строгая нормализация (strong normalization) для исчислений с полной (недетерминистской) бета-редукцией. Стандартный метод доказательства был изобретен Тейтом (1967); Жирар обобщил его на Систему F (1972, ???, ср. гл. 23); затем Тейт упростил доказательство (Tait, 1975). В этой книге мы адаптируем метод Тейта для вызова по значению, как это сделал Мартин Хофман (частное сообщение). Среди классических справочников по методу логических отношений — работы Говарда (Howard, 1973), Тейта (Tait, 1967), Фридмана (Friedman, 1975), Плоткина (Plotkin, 1973, Plotkin, 1980) и Стэтмана (Statman, 1982, Statman, 1985a, Statman, 1985b). Этот метод также описывается во многих работах по семантике, например, в книгах Митчелла (Mitchell, 1996) и Гантера (Gunter, 1992).

Доказательство строгой нормализации по Тейту в точности соответствует алгоритму вычисления термов с простой типизацией, известному как нормализация вычислением (normalization by evaluation) или частичное вычисление, управляемое типами (type-directed partial evaluation) (Berger, 1993, Danvy, 1998); см. также работы Бергера и Швихтенберга (Berger and Schwichtenberg, 1991), Филински (Filinski, 1999, Filinski, 2001) и Рейнольдса (Reynolds, 1998a).


1
В этой главе изучается язык простого типизированного лямбда-исчисления (рис. 9.1) с одним базовым типом A (11.1).
1
Иногда множества RT называют насыщенными множествами (saturated sets) или множествами кандидатов на редуцируемость (reducibility candidates).

Chapter 13  Ссылки

До сих пор мы рассматривали различные чистые (pure) языковые конструкции, в том числе функциональную абстракцию, базовые типы, такие как числа и булевские значения, и структурированные типы, такие как записи и вариантные типы. Эти конструкции составляют основу большинства языков программирования, включая чисто функциональные, такие, как Haskell, <<функциональные по большей части>>, такие, как ML, императивные (как C) и объектно-ориентированные (как Java).1

В большинстве практических языков программирования имеются также различные нечистые (impure) конструкции, которые невозможно описать в рамках простой семантической модели, использовавшейся нами до сих пор. В частности, помимо порождения результатов, вычисление термов в этих языках может приводить к присваиванию значений изменяемым переменным (адресуемым ячейкам, массивам, изменяемым полям записей, и т. п.); вводу и выводу в файлы, на дисплеи, по сетевым соединениям; нелокальной передаче управления с помощью исключений, переходов или продолжений; синхронизации и обмену информацией между процессами, и так далее. В литературе по языкам программирования такие <<побочные эффекты>> вычисления обычно называют вычислительными эффектами (computational effects).

В этой главе мы увидим, как одна из разновидностей вычислительных эффектов — изменяемые ссылки — может быть добавлена в изучаемые нами исчисления. Основное добавление будет заключаться в явных операциях с памятью (store) (или кучей, heap). Это расширение нетрудно определить; самое интересное начнется, когда возникнет необходимость уточнить теорему о сохранении типов (3). Еще один вид эффектов, исключения и нелокальную передачу управления, мы рассмотрим в главе 14.

13.1  Введение

Почти все языки программирования1 обладают в том или ином виде операцией присваивания (assignment), которая изменяет содержимое заранее выделенного фрагмента памяти. В некоторых языках, в частности, в ML и родственных ему, механизмы связывания имен и присваивания разделены. Можно иметь переменную x, значением которой является число 5, а можно иметь переменную y, значением которой является ссылка (reference) (или указатель, pointer) на изменяемую ячейку с текущим содержимым 5, и программисту видно это различие. Можно сложить x с другим числом, но присвоить ему новое значение нельзя. Переменную y можно напрямую использовать для присваивания нового значения ячейке, на которую она указывает (это записывается в виде y := 84), но ее нельзя просто употребить в качестве аргумента plus. Для этой цели требуется явно разыменовать (dereference) ссылку, написав !y для обращения к ее текущему содержимому. В большинстве других языков — в частности, во всех членах семейства C, включая Java, — каждое имя переменной обозначает изменяемую ячейку, и операция разыменования переменной с целью получения ее текущего значения производится неявно.2

С точки зрения формального исследования полезно разделять эти механизмы;3 наше описание в этой главе будет близко следовать модели ML. Уроки этого описания легко можно перенести на языки семейства C, если забыть некоторые различия и сделать кое-какие операции, вроде обращения по ссылке, неявными.

Основные понятия

Основные операции над ссылками — выделение памяти (allocation), разыменование (dereferencing) и присваивание (assignment). Для выделения памяти для ссылки используется оператор ref, которому в качестве аргумента дается начальное значение новой ячейки.

r = ref 5; |> r : Ref Nat

Ответ программы проверки типов указывает, что значением r является ссылка на ячейку, которая всегда будет содержать число. Чтобы прочитать текущее значение этой ячейки, мы используем оператор разыменования ! (<<восклицательный знак>>).

!r; |> 5 : Nat

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

r := 7; |> unit : Unit

(Результатом присваивания является вырожденное значение unit; см. §11.2.) Если мы снова разыменуем r, мы получим новое значение.

!r; |> 7 : Nat

Побочные эффекты и последовательность действий

То, что результатом выражения присваивания является вырожденное значение unit, хорошо сочетается с нотацией последовательного исполнения (sequencing), определенной в §11.3, которая позволяет писать

(r:=succ(!r); !r); |> 8 : Nat

вместо эквивалентного, но более громоздкого

(λ_:Unit. !r) (r := succ(!r)); |> 9 : Nat

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

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

(r:=succ(!r); r:=succ(!r); r:=succ(!r); r:=succ(!r); !r); |> 13 : Nat

Ссылки и псевдонимы

Необходимо помнить о различии между ссылкой (reference), привязанной к имени r, и ячейкой памяти (cell), на которую указывает эта ссылка.

(requals) r = ;(space)   ;
;

[draw,rectangle,thick,rounded corners,below right=1cm and .1cm of requals] (value) 13;

[->,thick] (space.center) – (value.north);

Если мы сделаем копию r, например, связав ее значение с другой переменной, s:

s = r; |> s : Ref Nat

то скопируется только ссылка (стрелка на диаграмме), а не ячейка:

 @C=.3em  **[rr =[dr]**[rs =[dll]13

Это можно проверить, присвоив s новое значение:

s := 82; |> unit : Unit

и считав его через r:

!r; |> 82 : Nat

Ссылки r и s называются псевдонимами (aliases) одной и той же ячейки.

Упражнение 1   [★]: Нарисуйте аналогичную диаграмму, показывающую результат выполнения выражений a = {ref 0, ref 0} и b = (λx:Ref Nat. {x,x}) (ref 0).

Разделяемое состояние

Наличие псевдонимов сильно усложняет рассуждения о программах, использующих ссылки. Например, выражение (r:=1;r:=!s), которое присваивает значение 1 переменной r, а затем немедленно затирает это значение текущим значением переменной s, работает точно так же, как простое присваивание r:=!s, за исключением случая, когда r и s являются ссылками на одну и ту же ячейку.

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

c = ref 0; |> c : Ref Nat incc = λx:Unit. (c := succ (!c); !c); |> incc : Unit -> Nat decc = λx:Unit. (c := pred (!c); !c); |> decc : Unit -> Nat

Вызов incc

incc unit; |> 1 : Nat

приводит к изменению значения c, и это изменение можно наблюдать при вызове decc:

decc unit; |> 0 : Nat

Если мы упакуем incc и decc в виде записи

o = {i = incc, d = decc}; |> o : {i:Unit -> Nat, d:Unit -> Nat}

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

Ссылки на составные типы

Ссылочная ячейка не обязана содержать всего лишь число: наши примитивы позволяют создавать ссылки на значения произвольного типа, включая функции. Можно, например, при помощи ссылок реализовать (правда, не очень эффективно) массивы чисел. Обозначим именем NatArray тип Ref (NatNat).

NatArray = Ref (Nat -> Nat);

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

newarray = λ_:Unit. ref (λn:Nat.0); |> newarray : Unit -> NatArray

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

lookup = λa:NatArray. λn:Nat. (!a) n; |> lookup : NatArray -> Nat -> Nat

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

update = λa:NatArray. λm:Nat. λv:Nat. let oldf = !a in a := (λn:Nat. if equal m n then v else oldf n;) |> update : NatArray -> Nat -> Nat -> Unit
Упражнение 2   [★★]: Если бы мы определили функцию update более компактно:
update = λa:NatArray. λm:Nat. λv:Nat. a := (λn:Nat. if equal m n then v else (!a) n;)
было бы ее поведение тем же самым?

Ссылки на значения, содержащие другие ссылки, также могут быть очень полезны, и с их помощью можно определять такие структуры данных, как изменяемые списки и деревья. (Как правило, такие структуры также используют рекурсивные типы (recursive types), о которых мы поговорим в главе 20.)

Сборка мусора

Последний вопрос, который следует упомянуть, прежде чем мы перейдем к формальному определению ссылок — освобождение (deallocation) памяти. Мы не вводим в язык никаких элементарных операций для освобождения ненужных ссылочных ячеек. Вместо этого, как во многих современных языках (включая ML и Java), мы полагаемся на то, что среда исполнения программ проводит сборку мусора (garbage collection), собирая и освобождая ячейки, которые перестали быть доступными из программы. Это не просто вопрос вкуса в проектировании языков: если в языке имеется явная операция освобождения памяти, то достижение типовой безопасности становится крайне сложной задачей. Причина этому кроется в широко известной проблеме висячих ссылок (dangling references): мы выделяем память под ячейку, содержащую число, сохраняем ссылку на нее в некоторой структуре данных, какое-то время ей пользуемся, затем освобождаем ее и выделяем новую ячейку, содержащую булевское значение. При этом, возможно, используется то же самое место в памяти. Теперь у нас может оказаться два имени для одной и той же ячейки памяти — одна с типом Ref Nat, а другая с типом Ref Bool.

Упражнение 3   Покажите, как это может привести к нарушению типовой безопасности.

13.2  Типизация

Правила типизации для ref, := и ! прямо следуют из поведения, которого мы от них ожидаем:

  
Γ ⊢ t1 : T1
Γ ⊢ ref t1 : Ref T1
  
Γ ⊢ t1 : Ref T1
Γ ⊢ !t1 : T1
  
Γ ⊢ t1 : Ref T1           Γ ⊢ t2 : T1
Γ ⊢ t1:=t2 : Unit

13.3  Вычисление

Более тонкие вопросы семантики ссылок возникают, когда мы начинаем формализовывать их операционное поведение. Например, зададим себе вопрос: <<Как должны выглядеть значения типа Ref T?>> Основной момент, который нам придется учесть, состоит в том, что вычисление оператора ref должно что-то делать (а именно, выделять память) и результатом операции должна быть ссылка на эту память.

Так что же такое ссылка?

Память времени исполнения в большинстве реализаций языков программирования представляет собой, в сущности, просто большой массив байтов. Среда времени исполнения следит за тем, какие области в этом массиве используются в каждый момент времени. Когда требуется создать новую ссылочную ячейку, мы выделяем из свободной области памяти сегмент нужного размера (4 байта для ячеек с целыми значениями, 8 байтов для ячеек со значениями типа Float, и т. д.), отмечаем этот сегмент как используемый, и возвращаем индекс его начала (как правило, в виде 32- или 64-битного целого). Эти индексы и служат ссылками.

Для наших целей такая конкретика пока не нужна. Мы можем рассматривать память как массив значений, а не байтов, абстрагируясь от того, что при исполнении различные значения имеют разные размеры в байтах. Кроме того, можно абстрагироваться от того, что ссылки (т. е., индексы массива) являются числами. Таким образом, мы считаем, что ссылки являются элементами некоторого множества L адресов памяти (store locations), точная природа которого неважна. Всё состояние памяти мы рассматриваем просто как частичную функцию из адресов l в значения. Для состояний памяти мы используем метапеременную µ. Таким образом, ссылка является адресом — абстрактным указателем на память. С этого момента мы используем термин адрес (location), а не ссылка (reference) или указатель (pointer), считая его более абстрактным.4

Теперь нужно расширить нашу операционную семантику и включить в нее изменения состояния памяти. Поскольку результат вычисления выражения в общем случае будет зависеть от содержимого памяти в момент вычисления, правила вычисления должны в качестве аргумента принимать не только терм, но и состояние памяти. Более того, поскольку вычисление терма может вызвать побочные эффекты в памяти, и эти эффекты могут затем повлиять на вычисление других термов, правила вычисления должны возвращать новое состояние памяти. Таким образом, общий вид одношагового вычисления из tt превращается в t∣µ → t∣µ′, где µ и µ′ — начальное и конечное состояние памяти. В сущности, мы усложнили свое понятие абстрактной машины (abstract machine) так, что состояние машины состоит теперь не только из указателя команд (представленного в виде терма), но еще и из текущего содержимого памяти.

Чтобы осуществить это изменение, прежде всего нужно дополнить состояниями памяти все имеющиеся правила вычисления:

  
(λx:T11.t12) v2 ∣µ  → [x ↦ v2]t12 ∣µ
  
t1 ∣µ → t1 ∣µ′
t1 t2 ∣µ → t1 t2 ∣µ′
  
t2 ∣µ → t2 ∣µ′
v1 t2 ∣µ → v1 t2 ∣µ′

Обратите внимание, что первое из этих правил возвращает состояние памяти µ неизменным: применение функции само по себе никаких побочных эффектов не имеет. Остальные два правила просто переносят побочные эффекты из предпосылки в заключение.

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

v::= значения:
  λx:T.tзначение-абстракция
  unitзначение единичного типа
  lадрес в памяти


Поскольку все значения также являются термами, множество термов тоже должно включать адреса.

t::= термы
  xпеременная
  λx:T.tабстракция
  t tприменение
  unitконстанта unit
  ref tпорождение ссылки
  !tразыменование
  t:=tприсваивание
  lадрес в памяти


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

Пользуясь расширенным синтаксисом, мы можем сформулировать правила вычисления для новых конструкций, которые работают с адресами и содержимым памяти. Во-первых, чтобы вычислить выражение разыменования !t1, нужно сначала редуцировать терм t1, пока он не превратится в значение:

  
t1 ∣µ → t1 ∣µ′
!t1 ∣µ → !t1 ∣µ′

Когда t1 будет сведен к значению, мы получим выражение вида !l, где l — некоторый адрес. Терм, пытающийся разыменовать любое другое выражение, скажем, функцию или unit, ошибочен. Правила вычисления в этом случае просто объявляют терм тупиковым. Теоремы о типовой безопасности из §13.5 гарантируют нам, что правильно типизированные термы никогда не окажутся в такой ситуации.

  
µ(l) = v
!l ∣µ → v ∣µ

Чтобы вычислить выражение присваивания t1 := t2, мы сначала вычисляем t1, пока он не станет значением (т. е., адресом),

  
t1 ∣µ → t1 ∣µ′
t1 := t2 ∣µ → t1 := t2 ∣µ′

а затем вычисляем t2, пока оно тоже не станет значением (любого вида):

  
t2 ∣µ → t2 ∣µ′
v1 := t2 ∣µ → v1 := t2 ∣µ′

Когда мы закончим вычислять t1 и t2, то получим выражение вида l := v2, которое мы выполняем, изменяя содержимое памяти так, чтобы по адресу l содержалось значение v2:

  
l := v2 ∣µ → unit ∣ [l ↦ v2

(Запись [lv2]µ здесь означает <<состояние памяти, где в l содержится v2, а по остальным адресам — то же, что и в µ>>. Обратите внимание, что на этом шаге вычисления результирующим термом является просто unit; интересная часть результата — обновленное состояние памяти.)

Наконец, чтобы вычислить выражение вида ref t1, мы сначала вычисляем терм t1, пока он не превратится в значение:

  
t1 ∣µ → t