Элементы функциональных языков

Евгений Кирпичёв

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

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


This article provides a comprehensive description of all the basic concepts usually attributed to functional languages, such as algebraic data types, closures or point-free style. Historical roots of all concepts are explained, their essence is illustrated, typical usage is displayed along with ways of imitating the concepts in languages that do not support them natively.

The article is targeted at professional programmers using mainstream programming languages, even if they do not plan to switch to functional languages. We aim to equip them with ideas from the world of functional programming.



Обсуждение статьи ведется по адресу
http://community.livejournal.com/fprog/5223.html.

1  Введение

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

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

В статье намеренно не затронуты две чрезвычайно важных темы: типовый полиморфизм, а также ленивые вычисления. Обе темы настолько широки, что достойное их рассмотрение увеличило бы и без того немалый размер статьи в полтора–два раза, а рассмотреть их кратко — значило бы лишить читателя удовольствия и обмануть относительно многогранности и красоты их применений. Эти темы будут рассмотрены в ближайших номерах. Кроме того, в данном номере опубликована статья Романа Душкина о полиморфизме в языке Haskell: она прекрасно подойдет для подготовки к знакомству со всем многообразием проявлений полиморфизма в программировании.

Структура всех глав примерно одинакова и состоит из следующих пунктов:

  1. Суть: описание концепции, изложенное в одном предложении.
  2. Краткая история, предпосылки и контекст возникновения концепции.
  3. Интуиция: пример, подготавливающий читателя к восприятию концепции; в идеале при прочтении этой секции читатель должен изобрести концепцию самостоятельно :)
  4. Более или менее подробное описание сущности концепции; однако, все сложные или длинные объяснения заменены ссылками на внешние источники.
  5. Применение: задачи, для решения которых концепция может оказаться полезной, а также указание на существующие программы, использующие данную концепцию.
  6. Реализации: перечисление языков, инструментов, библиотек, реализующих данную концепцию.
  7. Схожие концепции из других парадигм (императивной, логической, объектно-ориентированной) и языков, по сути своей схожие с данной, а также способы реализации или имитации данной концепции в рамках этих парадигм и языков.

2  Вывод типов
(Type inference)



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



История
Вывод типов также называется «неявной типизацией», или же «типизацией по Карри», в честь логика Хаскелла Карри. Роджер Хиндли в письме [76] утверждает, что алгоритм вывода типов при помощи унификации множество раз переоткрывался, в основном в период 1950—1960-х гг, или даже 1920—1930-х гг. Возможно, это объясняется чрезвычайной простотой, элегантностью и универсальностью общей схемы алгоритма: вероятно, авторы работали в разных областях математики и информатики, не знали о работах друг друга и не считали нужным публиковать данный алгоритм отдельно.



Интуиция
Рассмотрим написанную на псевдокоде программу для вычисления скалярного произведения двух двумерных векторов:

dot(v1,v2) = v1[0]*v2[0] + v1[1]*v2[1]

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

В этом рассуждении были использованы типы операций «+», «*», «[]», с помощью которых была составлена система ограничений, решение которой позволило выяснить типы dot, v1, v2.



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

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

Исходная информация для алгоритма вывода типов — сама программа, способ присваивания типов литералам (таким как 42.0f, “hello”, и т. п.), типы некоторых функций и значений, а также правила типизации синтаксических конструкций (типы встроенных арифметических операций, типы операций доступа к структурам данных, взятые из определений этих структур, и т. п.). Правила типизации синтаксических конструкций и литералов обычно фиксированы в рамках одного алгоритма вывода типов.

Наиболее известный, простой и широко применяющийся алгоритм вывода типов — алгоритм Хиндли—Милнера (описан в Wikipedia в статье «Type inference» [21]), пригодный для таких систем, как просто типизированное лямбда-исчисление, System F (ее ограниченное подмножество) и некоторых других. System F является основой систем типов большинства современных функциональных языков, таких как OCaml и Haskell. Она описана, например, в презентации Александра Микеля «An introduction to System F» [118], учебнике Жирара, Лафонта и Тейлора «Proofs and types» [71], и в классическом учебнике Бенджамина Пирса «Types and programming languages» [139].

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



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

В качестве примера полезности вывода типов лучше подойдет Java: начиная с версии 5 в этом языке появились «дженерики» (generics: классы и методы, параметризованные типами), и в некоторых случаях компилятор способен вывести конкретные значения типовых параметров при вызове метода, например:

List<String> strings = Collections.emptyList();

вместо

List<String> strings = Collections.<String>emptyList();

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

В программах на языках с мощной системой типов, скажем, языках семейства ML, лаконичность кода во многом обусловлена именно тем, что программист может не указывать типы большинства выражений. Обычно типы указывают только для объявлений верхнего уровня, в качестве документации. Например, вот фрагмент кода из библиотеки для чтения tar-архивов [46] на Haskell:

correctChecksum :: ByteString -> Int -> Bool correctChecksum header checksum = checksum == checksum' where -- sum of all 512 bytes in the header block, -- treating each byte as an 8-bit unsigned value checksum' = BS.Char8.foldl' (\x y -> x + ord y) 0 header' -- treating the 8 bytes of chksum as blank characters. header' = BS.concat [BS.take 148 header, BS.Char8.replicate 8 ' ', BS.drop 156 header]

В этом фрагменте кода не указаны типы checksum' и header', типы x и y в выражении \\x y -> x + ord y, а также тип значения, возвращаемого этой анонимной функцией.

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

До:

data Exp = IntE Int | OpE String Exp Exp instance Binary Exp where put (IntE i) = put (0 :: Word8) >> put i put (OpE s e1 e2) = put (1 :: Word8) >> put s >> put e1 >> put e2 get = do tag <- getWord8 case tag of 0 -> liftM IntE get 1 -> liftM3 OpE get get get

После: (это не совсем Haskell: в Haskell более сложный синтаксис явного указания типов)

data Exp = IntE Int | OpE String Exp Exp instance Binary Exp where put :: Exp -> Put put (IntE i) = ((>>) {Put}) (put {Word8} 0) (put {Int} i) put (OpE s e1 e2) = ((>>) {Put}) (put {Word8} 1) ((>>) {Put} (put {String} s) ((>>) {Put} (put {Exp} e1) (put {Exp} e2))) get :: Get Exp get = (>>=) {Get, Word8, Exp}) getWord8 (\ (tag::Word8) -> case tag of 0 -> liftM {Get, Int, Exp} IntE (get {Int}) 1 -> liftM3 {Get, String, Exp, Exp, Exp} OpE (get {String}) (get {Exp}) (get {Exp})



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

3  Функция высшего порядка (ФВП)
(Higher-order function (HOF))



Суть
Функция, принимающая на вход или возвращающая функцию.



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



Интуиция
Рассмотрим два отчасти взаимосвязанных примера:

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

С другой стороны, в движке СУБД процедура анализа секции ORDER BY должна принимать на вход ее синтаксическое дерево и возвращать способ сравнения строк таблицы согласно заданным в ней параметрам. В данном случае результатом одного вычислительного процесса является описание другого.

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

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



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

Например:

  1. процедура сортировки принимает на вход список данных и функцию сравнения элементов;
  2. в движке базы данных процедура анализа секции ORDER BY принимает на вход ее синтаксическое дерево и возвращает соответствующую процедуру сравнения строк таблицы;
  3. процедура численного дифференцирования принимает на вход спецификацию требуемой точности и вещественную функцию одного аргумента f, а возвращает также вещественную функцию, отображающую x в f′(x), вычисленное с указанной точностью;
  4. процедура вычисления композиции функций принимает на вход две функции f и g, а возвращает функцию h, отображающую x в f(g(x))

Вот несколько примеров типов функций высшего порядка на Haskell:

sort :: ((α, α) -> Bool, [α]) -> [α] analyzeOrderBy :: OrderBySection -> ((Row, Row) -> Bool) differentiate :: (Float, Float -> Float) -> (Float -> Float) compose :: (β -> gamma, α -> β) -> (α -> gamma)

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

isAsubstringOfB :: (String, String) -> Bool -- The following two notations are equivalent; the second -- is just an abbreviation of the first. isAsubstringOfB :: String -> (String -> Bool) isAsubstringOfB :: String -> String -> Bool

Из-за некоторой путаницы с тем, что понимать под порядком функции при наличии каррирования (считать ли isAsubstringOfB функцией второго порядка из-за того, что результат ее применения к одному аргументу — функция первого порядка типа String -> Bool?), иногда порядком функции считают глубину самой вложенной слева стрелки: ниже приведены три типа, соответствующих функциям первого, второго и третьего порядков.

isAsubstringOfB :: String -> String -> Bool foldr :: (α -> β -> β) -> β -> [α] -> β externalSort :: ((α -> α -> Bool) -> [α] -> [α]) -> FilePath -> Int -> (α -> α -> Bool) -> [α] -> [α]

Здесь foldr — процедура правой списочной свертки (см. 11).

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

В статье «Functional pearl: Why would anyone ever want to use a sixth-order function?» [126] Крис Окасаки показывает, какое практическое применение может быть у функций чрезвычайно высоких порядков, вплоть до шестого.



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

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

Многие функции высшего порядка оперируют на более абстрактном уровне, не над данными, но над другими функциями:

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

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

Функции высшего порядка часто используются вместе с бесточечным стилем (см. 6).

Функции высшего порядка позволяют уменьшить избыточность в программе и сократить код, что положительно влияет на корректность (меньше мест для совершения ошибки — меньше ошибок) и поддерживаемость (не надо делать изменения во множестве похожих мест в коде: достаточно изменить код, абстрагирующий их сходство). Особенно остро это проявляется в многопоточном программировании, где необходимо управлять несколькими процессами — скажем, «периодически выполнять заданное действие, не допуская, чтобы оно выполнялось одновременно два раза», или «параллельно применить функцию к каждому элементу списка», или «выполнить набор заданий, пользуясь не более чем 10 нитями», или «асинхронно выполнить заданное действие и в случае успеха передать его результат следующему действию» и т. п. Корректная реализация многопоточных алгоритмов — чрезвычайно трудоемкое дело со множеством нюансов. Этим объясняется тот факт, что библиотеки многопоточного программирования даже в императивных языках буквально напичканы функциями высшего порядка. Скажем, рассмотрим стандартную библиотеку Java; в ней процедуры (равно как и процедуры высшего порядка) моделируются при помощи объектов:

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

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



Реализация
Многие современные языки общего назначения (C, Pascal) позволяют манипулировать указателями на функции (многие компиляторы Fortran не позволяют даже этого).

Все функциональные языки (Haskell, OCaml, Scheme, Clean, ...) и большинство динамически типизированных языков (Python, Ruby, Javascript) позволяют манипулировать функциями как полноценными значениями, позволяют использовать замыкания (см. 4), анонимные функции (лямбда-выражения).

Функциональные языки с мощной системой статической типизации (Haskell, OCaml, Clean, ...) поощряют обильное использование функций высшего порядка.



Имитация
Способ имитации полноценных функций высшего порядка в языках семейства Си описан в секции «Интуиция».

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

interface Function<X,Y> { Y apply(X x); }

В стандартной библиотеке Java объекты, схожие по свойствам и назначению с функциями, и использующие их объекты, схожие, таким образом, с ФВП, встречаются достаточно часто:

4  Замыкание
(Closure)



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



История
Правила вычисления в лямбда-исчислении таковы, что лямбда-выражение может использовать переменные, связанные какой-нибудь из окружающих его лямбда-абстракций. Возьмем, например, ((λ x.(λ y.x))42)37 =⇒ (λ y.42)37 =⇒ 42: здесь выражение λ y.x «запоминает» значение определенного во внешней области видимости идентификатора x. Таким образом, идея замыканий появилась еще до появления программирования, в момент создания лямбда-исчисления — в 1930 гг. Термин «замыкание» был введен в 1964 г. Питером Ландиным. Замыкание — это пара из лямбда-выражения («кода») со свободными переменными и окружения, связывающего их. Внутри замыкания все переменные связаны, т. е. оно замкнуто. Первая полноценная реализация лексических замыканий относится к языку Scheme (диалект LISP, 1975 г.). В настоящее время они поддерживаются в той или иной форме почти всеми популярными языками общего назначения, кроме низкоуровневых, таких как C или C++ до версии C++0x (не считая эмуляции замыканий, например, при помощи boost::function — впрочем, надо отдать должное, достаточно удобной).

В языке LISP присутствовали замыкания с динамической областью видимости, нарушавшие законы лямбда-исчисления и потому, в общем случае, неудобные в использовании3. Emacs LISP — практически единственный используемый на практике современный язык программирования, где динамическая область видимости используется по умолчанию.



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

def map(f, list): for x in list: yield f(x)

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

def fetcher(dict): def fetch(k): return dict[k] return fetch

Затем при помощи этой функции достанем из словаря по списку ключей список значений.

def values(dict, keys): return map(fetcher(dict), keys)

Как видно, функция fetch использует значение аргумента dict функции values, потому объявлена в теле values.

Переменная dict не является аргументом или локальной переменной fetch — она является свободной относительно определения fetch. Однако, она является аргументом fetcher и потому связана относительно определения fetcher.

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

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

def fetcher(dict): return fetch def fetch(k): return dict[k] # Hey, how do we know which 'dict' to use?

Если объявлять процедуру fetch на верхнем уровне, то в контексте ее тела неоткуда взять dict, т. к. он не является и не может являться глобальной переменной.

К счастью, Python разрешает объявлять одни процедуры внутри других. В менее мощных языках можно попробовать применить «лямбда-поднятие (lambda lifting)» или «преобразование замыканий». Этот подход описан в статье об ФВП (см. 3). Представим n-аргументную функцию как пару из n+1-аргументной и значения вспомогательного n+1-го аргумента. При n=1 подход выглядит так:

def call(f_and_data, x): f,data = f_and_data return f(x, data) ... # f is a code/data pair def map(f, list): for x in list: yield call(f, x) ... def fetchFrom(x, data): # Unpack free variables from the # auxiliary argument dict = data return dict[k] def fetcher(dict): fetch = (fetchFrom, dict) return fetch ...

Однако уже в этом коде видна проблема: способы вызова обычных и «искусственных» процедур совершенно различны. Например, возвращенную «процедуру» fetch нельзя вызывать как fetch(...), равно как и в map нельзя передать в качестве f, скажем, процедуру upper из модуля string для перевода строк в верхний регистр, т. к. map ожидает уже не обычную процедуру, а пару из процедуры и ее вспомогательного аргумента. Чтобы восстановить равноправие двух появившихся разновидностей процедур, придется изменить всю программу, представляя все процедуры, потенциально используемые в качестве аргументов или результатов процедур высшего порядка, в форме со вспомогательным аргументом, что во всех случаях, кроме тривиальных, крайне неудобно и трудоемко.

Как видно, для использования ФВП необходима поддержка замыканий со стороны языка.



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

Замыкания жизненно важны для использования функций высшего порядка; без замыканий становится невозможно использовать сколько-нибудь полезную ФВП (как мы видели выше на примере map, написать полезную ФВП возможно, однако нетривиальные варианты ее использования оказываются недостижимыми).

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

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

def closestRestaurants(userPoint, n): return sorted(restaurants, lambda r1,r2: dist(r1,userPoint)-dist(r2,userPoint))[1:n]

Реализация замыканий связана с двумя вопросами, называемыми вместе «проблемой функционального аргумента (фунарг-проблемой)»4:

В языках с изменяемым состоянием встает вопрос о том, может ли замыкание изменять значения «захваченных» переменных. Разные языки подходят к этому вопросу по-разному: например, в Java это запрещено — замыкания моделируются при помощи анонимных классов, и все переменные, захватываемые анонимным классом, должны быть объявлены как final, и их значения при создании замыкания копируются (конечно, речь идет не о глубоком копировании объектов, а о копировании ссылок, или же о копировании значений для примитивных типов).

В Scheme разрешено изменять значения переменных из родительской области видимости. Для реализации этой функциональности используется «модель окружений»:

За подробным описанием модели окружений предлагается обратиться в один из авторитетных источников: например, в книгу «Структура и интерпретация компьютерных программ» [32] (глава 3.2).

В классической работе Стрейчи «Fundamental concepts in programming languages» [161] в секции 3.4.3 «Modes of free variables» также обсуждается вопрос изменяемости захваченных переменных.



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

Рассмотрим их по очереди.

Вот код на Scheme, реализующий счетчик при помощи замыкания:

> (define counter-from (lambda (n) (lambda () (set! n (+ n 1)) n))) > (define x (counter-from 5)) > (define y (counter-from 5)) > (x) 6 > (x) 7 > (y) 6

В этом примере x и y — два замыкания, каждое со своим собственным состоянием.

Таким образом, замыкания позволяют реализовать объекты, аналогичные объектам в ООП, то есть, значения, обладающие инкапсулированным внутренним состоянием и собственным набором операций, манипулирующих им (как мы увидим далее, верно и обратное: объекты позволяют эмулировать замыкания. Таким образом, объекты и замыкания в определенном смысле эквивалентны6). В данном случае был реализован объект-счетчик со всего одним безаргументным методом; рассмотрим объект чуть посложнее, с двумя «методами»: «получить значение счетчика» и «увеличить счетчик на заданное значение».

> (define counter-from (lambda (n) (cons % Construct a pair (lambda () n) (lambda (k) (set! n (+ n k)))))) > (define (get-n x) ((car x))) % car extracts the first component of a pair > (define (inc-n x k) ((cdr x) k)) % ... and cdr extracts the second one > (define x (counter-from 5)) > (get-n x) 5 > (inc-n x 3) > (get-n x) 8

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

Значительно более интересный пример использования замыканий с изменяемым состоянием для эмуляции объектов можно найти в главах 3.3.4 и 3.3.5 книги «Структура и интерпретация компьютерных программ» [32]: в них строится симулятор цифровых схем и симулятор схем с распространением ограничений.

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

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

Например, можно взять вместо вещественных чисел логические значения, вместо умножения — операцию «И», вместо сложения — операцию «ИЛИ», а вместо нуля — False.

Матрицы над этим полукольцом полезны, в том числе, для поиска компонент связности графа (при возведении матрицы инцидентности в n-ю степень получится матрица, в чьем (i,j)-м компоненте стоит True, если и только если вершины i и j связаны путем длиной в n−1 шаг). Если же рассмотреть матрицы над вещественными числами и полукольцом, где вместо сложения и умножения — операции взятия сложения и минимума (это полукольцо называется «тропическим»), то такие матрицы можно использовать для поиска кратчайших путей в графах. У них есть и другие применения: см., например, статью Дэна Пипони «An Approach to Algorithm Parallelization» [140].

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

> (define (make-semiring @+@ @*@ +0+) (list @+@ @*@ +0+)) > (define reals-semiring (make-semiring + * 0)) > (define (make-linalg semiring) (define @+@ (semiring-plus semiring)) (define @*@ (semiring-mult semiring)) (define +0+ (semiring-plus-unit semiring)) ... (define (scalar-prod u v) (vec-foldl @+@ +0+ (vec-zip @*@ u v))) (define (mat*mat m1 m2) (let ([m2t (mat-transpose m2)]) (vec-map (lambda (r1) (vec-map (lambda (c2) (scalar-prod r1 c2)) m2t)) m1))) ... (lambda request (match request [`(make-vec ,xs) (make-vec xs)] [`(build-vec ,n ,f) (build-vec n f)] [`(vec-ref ,v ,i) (vec-ref v i)] [`(scalar-prod ,u ,v) (scalar-prod u v)] ... [`(mat*mat ,m1 ,m2) (mat*mat m1 m2)]))) > (define (mat*mat lib m1 m2) (lib 'mat*mat m1 m2)) > ... > (define r-lib (make-linalg reals-semiring)) > (define u (make-vec r-lib '(1 2 3))) > (define v (make-vec r-lib '(4 5 6))) > (scalar-prod r-lib u v) 32

В данном случае make-linalg принимает на вход функцию сложения, функцию умножения и нейтральный элемент по сложению, и возвращает набор процедур линейной алгебры над этими операциями. Как видно, возвращается процедура, принимающая на вход произвольный запрос, сопоставляющая (см. 10) его с несколькими образцами и вызывающая одну из «рабочих» процедур, определенных внутри make-linalg. Эти процедуры являются замыканиями и используют данные из semiring и из локальных переменных @+@, @*@, +0+.

Данный пример также иллюстрирует имитацию классов типов (см. 13) при помощи «dictionary-passing style» (см. в статье о классах типов): имитируется класс типов «Полукольцо» примерно следующего вида:

class Semiring tau where (+) :: tau -> tau -> tau (*) :: tau -> tau -> tau zero :: tau -- For example: instance Semiring Bool where (+) = (||) (*) = (&&) zero = False



Реализация
Практически все появляющиеся в последнее время языки поддерживают замыкания. Замыкания реализованы в той или иной форме в большом количестве функциональных (Haskell, OCaml, Lisp, F# и др.), динамических (Python, Ruby и др.) и императивных (C# и др.) языков.

«Классический» и наиболее интуитивно понятный вариант поддержки замыканий с моделью окружений реализован в языке Scheme.

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

Javascript известен своими неочевидными правилами, связанными с областями видимости (см. блог-пост «A huge gotcha with Javascript closures and loops» Кейта Ли [108]); эти же проблемы есть также и в C# (см. пост на StackOverflow «Problem with delegates in C#» [16] с примером такой проблемы, а также статью «The Beauty of Closures» [155] о различиях в стратегиях захвата переменных в C# и Java).

Замыкания можно ограниченно смоделировать при помощи анонимных классов в Java. Существуют планы включить более удобную синтаксическую поддержку замыканий в Java 7. Из-за отсутствия общей точки зрения на детали реализации и синтаксиса эта возможность была отложена, однако в ноябре 2009 г. ситуация неожиданно изменилась ([146]), и, возможно, замыкания все же будут включены в язык (впрочем, в предложенном варианте имеется множество проблем, описанных Нилом Гафтером в письме [67]).

Замыкания также планируется включить в стандарт C++0x (однако без автоматического управления памятью их полезность несколько снижается; в частности, стандарт обязывает программиста явно указывать, желает ли он копировать значения свободных переменных или же использовать их по ссылке — но в этом случае их время жизни ограничено временем жизни области видимости, создавшей их, а за ее пределами обращение к такой переменной порождает «неопределенное поведение»).

Python реализует замыкания сходным с Java образом: модифицировать захваченные переменные запрещено (хоть это и проверяется во время выполнения, а не во время компиляции).



Имитация
Часть способов имитации замыканий описана в статье о функциях высшего порядка (см. 3).

Процесс устранения замыканий с помощью введения вспомогательных аргументов называется преобразованием замыканий (closure conversion), или лямбда-поднятием (lambda lifting) и применяется в качестве стадии компиляции во многих компиляторах функциональных языков. Это преобразование было предложено Томасом Джонсоном в статье «Lambda lifting: transforming programs to recursive equations».

В объектно-ориентированных языках замыкания и лямбда-выражения можно имитировать с помощью объектов, объектных литералов или анонимных классов.

Также существует техника дефункционализации — способ преобразования программы, использующей функции высшего порядка («программы высшего порядка») в программу первого порядка, использующую только функции первого порядка и дополнительные структуры данных. Дефункционализация хорошо описана в статье «Defunctionalization at work» Оливье Дэнви и Лассе Нильсена [58], а также в потрясающей диссертации Нила Митчелла «Transformation and analysis of functional programs» [119] и в его отдельной статье [120] и слайдах [121] о дефункционализаторе firstify «Losing functions without gaining data».

При дефункционализации программ высшего порядка зачастую получаются знакомые и несколько неожиданные алгоритмы: к примеру, из программы, составляющей список узлов дерева при помощи разностных списков, получается программа, использующая хвосторекурсивный алгоритм (см. 7) с аккумулятором. Разностные списки были предложены Джоном Хьюзом в статье «A novel representation of lists and its application to the function reverse».

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

Дефункционализация также используется и для оптимизации программ высшего порядка, поскольку часто производительность дефункционализованной программы оказывается выше за счет использования более простых, эффективных и «близких к железу» операций. Дефункционализация не решает проблемы управления памятью под свободные переменные замыканий и по-прежнему требует сборки мусора либо ручного управления. Однако существуют другие техники, позволяющие частично или полностью решить проблемы управления памятью: например, т. н. типы владения (ownership types) (описаны в статье «Ownership types for object encapsulation» [44], один из авторов которой — Барбара Лисков), вывод регионов (region inference) (описан в статье «Region-based memory management» Мэдса Тофте и Жана-Пьера Талпена [167] и в книге «Design concepts in programming languages» [169]).

5  Частичное применение, карринг и сечения
(Partial application, currying and sections)



Суть
Фиксацией некоторых аргументов из функции получается новая функция с меньшим числом аргументов.



История
Согласно классическому труду Хенка Барендрегта «Lambda calculi with types» [36] и статье Хиндли и Кардоне «History of lambda-calculus and combinatory logic» [47], понятие карринга было введено Шейнфинкелем в 1924 г. при создании комбинáторной логики (статья «Über die Bausteine der mathematischen Logik» [151], перевод на английский язык «On the building blocks of mathematical logic» [152]) для избавления от нужды в функциях с несколькими аргументами — как один из этапов перехода от «традиционного» математического языка к языку комбинаторной логики, где понятие аргумента и функции вовсе отсутствуют. Позднее (в 1930 гг. и далее) это понятие использовалось Хаскеллом Карри (он, однако, много раз явно указывал на роль Шейнфинкеля как «первооткрывателя»), и было названо по его имени «каррингом» уже Кристофером Стрейчи в 1967 г. в его знаменитой работе «Fundamental concepts in programming languages» [161]7.



Интуиция
Рассмотрим процедуру, определяющую, удовлетворяет ли строка регулярному выражению.

matchesRegexp :: String -> String -> Bool matchesRegexp pattern string = ...

Применим ее, скажем, для валидации целых чисел:

isNumber s = matchesRegexp "-?[0-9]+" s

Получается: «Число — это такая строка s, что s удовлетворяет регулярному выражению -?[-0-9]+». Но почему бы не сказать покороче? «Число — это то, что удовлетворяет регулярному выражению -?[0-9]+»:

isNumber = matchesRegexp "-?[0-9]+"

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

makeMatcher :: String -> (String -> Bool) makeMatcher pattern = matcher where matcher s = matchesRegexp pattern s isNumber = makeMatcher "-?[0-9]+"

Наличие процедуры makeMatcher позволяет не плодить вспомогательные функции и лямбда-абстракции вида \s -> matchesRegexp “-?[0-9]+” s или isNumber s = matchesRegexp “-?[0-9]+” s8.



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

Частичным применением n-арной функции f называется конструкция, значением которой является (nk)-арная функция, соответствующая f при фиксированных значениях некоторых k из n аргументов. Например, в гипотетическом языке программирования частичное применение функции drawLine может выглядеть вот так:

drawLine :: (width:Double, color:Color, style:LineStyle, a:Point, b:Point) -> void thinSolidLine :: (color:Color, a:Point, b:Point) -> void thinSolidLine = drawLine(1.0, _, SOLID, _, _)

В данном случае фиксируются 2 аргумента 5-арной функции drawLine и получается 3-арная функция thinSolidLine.

У термина «карринг» есть три взаимосвязанных значения.

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

thinLine :: (color:Color, style:LineStyle, a:Point, b:Point) -> void thinLine = drawLine 1.0

В этом примере thinLine получен фиксацией первого аргумента drawLine9.

Второе — превращение функции F над 2-местным кортежем (парой с типами компонентов X и Y) в функцию над X, возвращающую функцию над Y (такая функция называется «каррированной» версией F):

matchesRegexpUncurried :: (String,String) -> Bool matchesRegexpUncurried = ... matchesRegexpCurried :: String -> (String -> Bool) matchesRegexpCurried pattern = matcher where matcher s = matchesRegexpUncurried (pattern,s)

В этом примере matchesRegexpCurried называется каррированной версией matchesRegexpUncurried, а сам процесс выражения matchesRegexpCurried через matchesRegexpUncurried называется каррингом.

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

curry :: ((α, β) -> gamma) -> (α -> β -> gamma) curry f = f' where f' x y = f (x,y) -- Other equivalent variants: curry f = \x y -> f (x,y) curry f x y = f (x,y) -- Or even: curry f x = \y -> f (x,y) uncurry :: (α -> β -> gamma) -> ((α, β) -> gamma) uncurry f = f' where f' (x,y) = f x y -- Or: uncurry f = \ (x,y) -> f x y uncurry f (x,y) = f x y

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

f :: String -> Int -> Bool -> Double f :: String -> (Int -> (Bool -> Double)) g = f "a" 5 True g = ((f "a") 5) True matchesRegexp :: String -> String -> Bool matchesRegexp :: String -> (String -> Bool) isNumber x = matchesRegexp "-?[0-9]+" x isNumber x = (matchesRegexp "-?[0-9]+") x

Третье значение термина «карринг» — применение каррированной функции к аргументам:

isNumber = matchesRegexpCurried "-?[0-9]+"

В таком случае говорят, что процедура isNumber получена каррированием процедуры matchesRegexpCurried.

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

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

> map (+5) [1,2,3] [6,7,8] > map (5-) [1,2,3] [4,3,2]

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

> let elem x list = any (==x) list > map (5 `elem`) [[5,1,2], [3,4], [6,5,1]] [True, False, True] > map (`elem` [5,10,15]) [2,10,3,5] [False, True, False, True]

Эти варианты применения называются соответственно левым и правым сечениями функции.



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

Из игры «Space invaders» (hinvaders) [143]:

moveSprite :: Coordinate -> Sprite -> Sprite moveSprite (dx, dy) (Sprite (sx,sy) ...) = Sprite (sx + dx, sy + dy) ... moveSprites :: Coordinate -> [Sprite] -> [Sprite] moveSprites (dx,dy) sprites = map (moveSprite (dx,dy)) sprites

В этом примере используется карринг функции moveSprite.

Из утилиты «Bookshelf» [33] для каталогизации документов:

-- Files/directories with an associated @.ignore@ file -- are excluded from the results. getShelfContents :: FilePath -> IO ([FilePath], [FilePath], [FilePath], [FilePath]) getShelfContents path = do (ds,fs) <- getDirectoryContentsSeparated path let (ignores, fs') = partition ((".ignore"==) . takeExtension) fs -- (1) ignores' = map dropExtension ignores files = filter (`notElem` ignores') fs' -- (2) dirs = filter (`notElem` ignores') ds -- (3)

В строках (1), (2) и (3) используются сечения: соответственно левое, правое и снова правое.

Из библиотеки для работы с zip-потоками zlib [56]:

-- (module Zlib.Internal) decompress :: Stream.Format -> DecompressParams -> L.ByteString -> L.ByteString decompress = ... -- (module Zlib) decompressWith :: DecompressParams -> ByteString -> ByteString decompressWith = Internal.decompress zlibFormat decompress :: ByteString -> ByteString decompress = decompressWith defaultDecompressParams

Функции decompressWith и decompress определены через друг друга при помощи карринга и втроем предоставляют различные уровни настраиваемости декомпрессии.

Карринг играет очень большую роль в удобстве пользования функциями высшего порядка (см. 3) и бесточечным стилем (см. 6). Это видно, например, в приведенном выше фрагменте кода из Bookshelf.

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

matchesRegexp regex = \s -> runCompiledRegex c s where c = compile regex

При другом порядке аргументов такая оптимизация была бы затруднена.



Реализация
Карринг реализован почти во всех языках семейства ML, в частности в Haskell, OCaml, F#, Scala (однако в Scala синтаксис объявления каррируемых и не-каррируемых функций различается), Coq и других. В языке OCaml реализован более удобный вариант частичного применения за счет поддержки именованных параметров; см. блог-пост «Curried function and labeled arguments in Ocaml» Педро дель Галлего [59]. В Scala также реализован довольно удобный вариант частичного применения: неявный аргумент обозначается за \_, например, goodThings.contains(\_) обозначает функцию, по x возвращающую goodThings.contains(x).

С точки зрения компиляции в эффективный код поддержка карринга в языке имеет свои тонкости: см., например, статью «Making a fast curry: push/enter vs. eval/apply for higher-order languages» [112] от авторов компилятора GHC Саймона Марлоу и Саймона Пейтона-Джонса, однако существуют техники преобразования программ (дефункционализация: см. ссылки в главе о функциях высшего порядка (см. 3), в секции «Имитация»), позволяющие получить из программы эквивалентную ей программу, не использующую карринг и функции высшего порядка и, вследствие этого, лучше поддающуюся компиляции в эффективный код.

В языках, поддерживающих процедуры с нефиксированным числом параметров, карринг в общем случае реализовать невозможно, т. к. если foo — процедура с произвольным числом параметров, то непонятно, как интерпретировать, скажем, foo a b c: как применение foo к трем аргументам a,b,c, или же как функцию от оставшихся аргументов (по-прежнему произвольного количества)? Например, к таким языкам относятся диалекты Lisp, в частности, Scheme, а также Ruby, Python, Perl и т. п. Впрочем, в таких языках, конечно же, остается возможность использовать карринг для процедур, число аргументов которых известно. Например, вот процедура curry на Scheme, аналогичная приведенной выше процедуре на Haskell:

> (define (curry f) (lambda (x y) (f (cons x y)))) > (define (plus xy) (+ (car xy) (cdr xy))) > (plus (cons 1 2)) 3 > ((curry plus) 1 2) 3



Имитация
Имитация карринга возможна при помощи процедур, аналогичных определенной выше процедуре curry. Для этого, очевидно, необходимо, чтобы язык поддерживал замыкания (см. 4) (поскольку замыкание, возвращаемое curry, использует аргумент curry, т. е. «замыкается» над каррируемой процедурой) и функции высшего порядка (см. 3) (т. к. процедура curry, принимая на вход функцию и возвращая функцию, является ФВП).

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

Pattern isNumber = Pattern.compile("-?[0-9]+"); ... if(isNumber.matcher(s).matches()) {...}

Существует также интересный паттерн проектирования «Curried Object» (описан в статье Джеймса Нобла «Arguments and results» [123]). Одно из его применений для облегчения многопоточной работы с изменяемым состоянием рассмотрено в статье «Изменяемое состояние: опасности и борьба с ними» Евгения Кирпичёва ([185]).

6  Бесточечный стиль
(Point-free style)



Суть
Сборка функций из других функций при помощи комбинаторов, без явного упоминания аргументов.



История
Идея описания функций без обращения к их аргументам берет свои корни из математики. Скажем, оператор Гамильтона («набла») определяется так:

∇ = 
∂ 
∂ x
x  + 
∂ 
∂ y
ŷ +
∂ 
∂ z

.

В 1924 г., еще до создания лямбда-исчисления, Шейнфинкель создал комбинáторную логику — формализм, подобный лямбда-исчислению, однако не содержащий лямбда-абстракции, и, таким образом, избегающий необходимости в использовании переменных. Вместо лямбда-абстракции комбинаторная логика предоставляет набор базовых комбинаторов и правил редукции; в результате получается также Тьюринг-полный вычислительный формализм (при этом следует помнить, что комбинаторная логика создавалась до формализма Тьюринга и до появления компьютеров как таковых). Комбинаторная логика оказала огромное влияние на современные функциональные языки программирования семейства ML, такие как Haskell.

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

В современном понимании этого слова бесточечный стиль был, вероятно, впервые описан Джоном Бэкусом в его знаменитой лекции «Can programming be liberated from the Von Neumann style?» ([35]), прочтенной им на церемонии вручении премии Тьюринга в 1977 году. Бесточечный стиль описывается в ней на примере манипуляций со списками, векторами и матрицами. Таким образом демонстрируется удобство формального манипулирования бесточечными определениями для доказательства свойств определенных подобным образом функций.

Практическое применение бесточечный стиль нашел в вышеупомянутых языках семейства ML, развивавшихся с начала 1970 гг.

Пожалуй, последнее из существенных событий в истории бесточечного стиля — появление в начале 1990 гг. языка J, наследника APL. О нем см. ниже в секции «Реализация».



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



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

Такой стиль часто позволяет уменьшить количество лишней информации в определении функции, делая его более лаконичным и читаемым (см. пример в предыдущей секции), хотя и требует определенного привыкания. С другой стороны, злоупотребление бесточечным стилем влияет на читаемость крайне негативно: ср.  \\f g x y -> f (g x y) против (.).(.).



Использование
Бесточечный стиль в основном применяется в языках, поддерживающих каррирование (см. 5), функции высшего порядка (см. 3) и обладающих мощной системой типов — таких, как Haskell, Clean, OCaml, F#. Однако, из соображений читаемости, бесточечные определения целых функций используются очень редко. Несравнимо чаще выражения в бесточечном стиле используются как часть определения. Рассмотрим несколько реальных примеров использования бесточечного стиля в программах на Haskell (намеренно выбраны очень простые примеры).

Пример из аркады «Monadius» [166]:

keyProc keystate key ks _ _ = case (key,ks) of (Char 'q',_) -> exitLoop (Char '\ESC',_) -> exitLoop (_,Down)-> modifyIORef keystate (nub . (++[key])) -- (1) (_,Up) -> modifyIORef keystate (filter (/=key)) -- (2)

Код, отмеченный (1), (2), означает: При приходе сигнала нажатия клавиши key произвести над ячейкой keystate модификацию «добавить key и удалить дубликаты (nub)»; при сигнале отпускания — модификацию «убрать key». Первый из отмеченных фрагментов с использованием «точечного» стиля выглядел бы как \\s -> nub (s ++ [key]), второй — как \\s -> filter (\\k -> k /= keys.

Пример из комбинаторной библиотеки graphics-drawingcombinators [130]:

colorFunc :: (Color -> Color) -> Draw α -> Draw α colorFunc cf = ... -- | @color c d@ sets the color of the drawing to exactly @c@. color :: Color -> Draw α -> Draw α color c = colorFunc (const c)

Процедура colorFunc принимает на вход функцию преобразования цвета (например, увеличение прозрачности в 2 раза выглядело бы как \\(r,g,b,a) -> (r,g,b,a/2)) и картинку, а возвращает картинку, где цвет каждой точки изменен соответствующим образом. Процедура color закрашивает всю картинку одним цветом, подставляя в colorFunc функцию, везде равную заданному цвету. В «точечном» стиле процедура color выглядела бы так:

color c pic = colorFunc (\clr -> c) pic

А в «еще более бесточечном» — так:

color = colorFunc . const

Пример из программы «jarfind» для поиска по jar-файлам [101]:

run :: Args -> IO () run (Args dataSource searchSource searchTarget) = do classes <- parseDataSource dataSource (mapM_ (putStrLn . present) . concatMap (search searchSource searchTarget)) classes

parseDataSource по описанию источника данных возвращает поток разобранных class-файлов. search по спецификации того, в каких классах искать (задается с помощью searchSource), что именно искать (сами классы или же методы/поля — searchTarget) и class-файлу возвращает список результатов (пар из найденного элемента и пути к файлу, содержащему его). В последней строчке данного примера написано: «Выполнить поиск во всех class-файлах из classes, сложить результаты, отформатировать и отобразить каждый из них».

И, наконец, пример использования бесточечного стиля на Java, из практики автора: фрагмент кода обнаружения DoS-атак по логам веб-сервера (см. также статью о комбинаторных библиотеках (см. 14)):

private LogFunction<Double> maxSpeedOverSessions(double sessionBreakCoeff) { SessionFunction<List<Double>> movingSpeed = moving(SPEED_WINDOW, weightedSpeed(WEIGHT_FUNCTION)); SessionFunction<List<Double>> movingSpeedOverXml = over(pathContains(".xml"), movingSpeed); SessionFunction<Double> maxSpeed = aggregate(max(0.0), movingSpeedOverXml); return aggregate(max(0.0), mapValues(bySession(sessionBreakCoeff, maxSpeed))); }

«Сессией» называется удовлетворяющий особым условиям участок последовательности запросов от одного IP-адреса. LogFunction<T> — «функция из лога в значение типа T», аналогично SessionFunction. Данный фрагмент кода манипулирует различными примитивными функциями и комбинаторами (к примеру, комбинатор moving соответствует применению заданной функции к временным подокнам определенной длительности) и собирает из них функцию, вычисляющую по логу скорость самой быстрой последовательности запросов.

И несколько воображаемых примеров:

scientificMindset = all (>4.5) . map (mean . snd) . scienceMarks where scienceMarks = filter (scienceSubj . fst) . marks scienceSubj = (`elem` ["maths", "physics"])



Реализация
Как уже говорилось выше, бесточечный стиль в основном применяется в языках семейства ML: Haskell, F# и т. п. Поддержка системы типов необходима для контроля за корректностью программ с использованием ФВП; ФВП необходимы в роли комбинаторов, собирающих более сложные функции из более простых; частичное применение избавляет от нужды в имитирующих его комбинаторах (к примеру, не будь карринга — понадобился бы комбинатор «зафиксировать первый из двух аргументов»: bind1st f x = \\y -> f x y — что и имеет место, к примеру, в C++ STL).

Также бесточечный стиль обильно используется в языках семейства APL, в т. ч. J (сайт [12]) и K ([13]). Чрезвычайно интересны и самобытны средства, предоставляемые для бесточечного программирования в языке J, где подавляющее большинство функций принято определять бесточечно. Язык J вводит понятия монадных (не путать с монадами из Haskell) и диадных глаголов (унарных и бинарных операций), «наречий» (модификаторов, применяемых к монаде или диаде для образования новой) а также вилок и крючков — особых синтаксических комбинаций нескольких монад или диад, образующих вместе новую монаду или диаду. Так, классический пример использования «вилки» и вообще краткости и стиля языка J — вычисление арифметического среднего:

avg =. +/ % #

Эту фразу следует читать как «среднее есть сумма, поделенная на длину», и она образована вилкой из монады суммирования списка +/ (где / — наречие свертки (см. 11)), диады деления \% и монады взятия длины \#. Описание разнообразных видов вилок и крючков можно найти, например, в онлайн-книге Роджера Стокса «Learning J», в главе 8 «Composing verbs» ([160]).

Наконец, стековые (конкатенативные) языки, такие как FORTH или Factor, также по своей сути располагают к бесточечному программированию.

Имитация
Использование бесточечного стиля в нефункциональных языках, не поддерживающих функции высшего порядка и частичное применение, сводится к имитации функций высшего порядка (например, с помощью объектов) и к имитации частичного применения (например, при помощи паттерна «Curried Object» (описан в статье Джеймса Нобла «Arguments and results» [123]). Пример использования бесточечного стиля на Java можно найти в статье и презентации «Функциональное программирование в Java» Евгения Кирпичёва ([183], [184]). Однако такое использование оправдано лишь в специфических случаях, когда действительно необходима крайне низкая синтаксическая нагрузка на конструирование функций при помощи комбинаторов (в основном, задачи сложной обработки данных).

7  Хвостовой вызов
(Tail call)



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



История
Первый вычислительный формализм, в котором вообще можно говорить о «вызовах» — лямбда-исчисление. Семантика бета-редукций (аналог «вызова функции») в лямбда-исчислении основана на подстановке, а не на, скажем, операциях над стеком параметров и адресов возврата, поэтому можно сказать, что вычислители, основанные на лямбда-исчислении, поддерживают оптимизацию хвостовых вызовов в изложенном далее по тексту смысле. Несмотря на то, что язык LISP был фактически реализацией лямбда-исчисления, стандарт Common LISP не обязывает оптимизировать хвостовые вызовы. Первый язык программирования, поддерживающий оптимизацию хвостовых вызовов — Scheme (1975). Оптимизация хвостовых вызовов была впервые описана в статье «Lambda: The Ultimate GOTO» [95] из серии фундаментальных статей «Lambda Papers» Гая Стила и Джеральда Сассмана [159]. Статья описывает и способ реализации этой возможности языка, и ее применения11.



Интуиция
Рассмотрим две простые процедуры над двоичными деревьями: поиск в двоичном дереве поиска и свертку (см. 11).

Будем считать, что деревья определены как алгебраический тип (см. 9) data Tree α = Leaf | Fork α (Tree α) (Tree α).

memberOf :: (Ord α) => α -> Tree α -> Bool _ `memberOf` Leaf = False x `memberOf` Fork y l r | x == y = True | x < y = x `memberOf` l | x > y = x `memberOf` r

Структура решения такова: есть два «крайних» случая (пустое дерево Leaf и «вилка» Fork с искомым значением), а в каждом из двух оставшихся случаев задача поиска в дереве t заменяется на задачу поиска в другом дереве — в левой или правой ветке t. Если x < y, то отыскать x в Fork y l r — то же самое, что и отыскать x в l, и аналогично при x > y, то есть, ответ к исходной задаче совпадает с ответом к другой задаче.

Теперь рассмотрим свертку и небольшой пример ее применения — вычисление высоты дерева.

foldTree :: (α -> β -> β -> β) -> β -> Tree α -> β foldTree fork leaf Leaf = leaf foldTree fork leaf (Fork x l r) = fork x vl vr where vl = foldTree fork leaf l vr = foldTree fork leaf r height = foldTree (\x hl hr -> 1 + max hl hr) 0

Структура решения такова: есть «крайний» случай — пустое дерево — и случай, требующий вычисления свертки в левом и правом поддереве и комбинирования результатов. Задача вычисления свертки над Fork x l r сводится к вычислению сверток над l и над r, то есть, ее ответ выражается через ответы других задач.

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



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


Рис. 1: Бинарное дерево, высота которого вычисляется в примере.

Вычисление высоты дерева на рис. 1 будет протекать так:

     
 
 
height (Fork 6 (Fork 2 (Fork 1 Leaf Leaf)  (Fork 3 Leaf Leaf))  (Fork 8 Leaf Leaf))
 
         
  (Обозначим (Fork n ...) как @n для краткости)          
 
 = 1 + max (
height @2
) (height @8) 
         
 
 = 1 + max (1 + max (
height @1
) (height @3)) (height @8) 
         
 
 = 1 + max (1 + max (1 + max (
height Leaf
) (height Leaf)) (height @3)) (height @8) 
         
 
 = 1 + max (1 + max (1 + max 0 (
height Leaf
)) (height @3)) (height @8) 
         
 
 = 1 + max (1 + max (1 + max 0 0) (
height @3
)) (height @8) 
         
  = …          
  = 3          

Запишем этот же вычислительный процесс в другой форме: будем указывать аргументы текущего вызова, а также то, как выражается окончательный результат через результат текущего вызова (то есть, «продолжение» текущего результата). Для краткости будем писать h вместо height.

h @6  • 
h @21+max •(h @8)
h @11+max (1+max • (h @3)) (h @8)
h Leaf1+max (1+max (1+max • (h Leaf)) (h @3)) (h @8)
h Leaf1+max (1+max (1+max 0 •) (h @3)) (h @8)
h @31+max (1+max 1 •) (h @8)
h Leaf1+max (1+max 1 (1+max • (h Leaf))) (h @8)
h Leaf1+max (1+max 1 (1+max 0 •)) (h @8)
h @81+max 2 • 
h Leaf1+max 2 (1+max • (h Leaf)
h Leaf1+max 2 (1+max 0 •)
3  • 

Как видно, в данном процессе форма выражения окончательного результата через результат текущего вызова все усложняется по мере «погружения» рекурсивных вызовов в структуру данных. Представить такое продолжение можно в виде композиции стека «локальных» продолжений, имеющих в данной программе вид 1+max • (height …) или 1+max … • (продолжение текущего вызова относительно вызывающей процедуры).

Теперь рассмотрим поиск в дереве. Согласно системе уравнений (см. 10), задающих memberOf, можно записать следующую цепочку равенств:

     
  memberOf 3 @6          
  = memberOf 3 @2          
  = memberOf 3 @3          
  = True          

И то же в форме с «продолжениями»:

memberOf 3 @6
memberOf 3 @2
memberOf 3 @3
True

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

Итак, вызов называется хвостовым, если его продолжение в контексте вызывающей процедуры — «•». Эта точная формулировка соответствует принятым туманным определениям, вроде «вызов — последнее предложение (statement) в теле функции и является непосредственным аргументом return» и т. п.

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

При хвостовой рекурсии хвостовой вызов процедуры происходит из этой же самой процедуры. Это имеет место, например, в приведенной процедуре memberOf.

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

even x = x==0 || x/=1 && odd (x-1) odd x = x==1 || x/=0 && even (x-1) > map even [0..5] [True,False,True,False,True,False] > map odd [0..5] [False,True,False,True,False,True]

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

height t = height' t id where height' Leaf k = k 0 height' (Fork x l r) = height' l (\hl -> height' r (\hr -> 1 + max l r))

Здесь функции foldTree' в явной форме передается продолжение для ее результата. Рассмотрим для примера вычисление высоты более простого, чем рассмотрено выше, дерева: Fork 2 (Fork 1 Leaf LeafLeaf.

Первый аргументВторой аргументПродолжение
h @2  • 
h @11+max • (h Leaf)
h Leaf1+max (1+max • (h Leaf)) (h Leaf)
h Leaf1+max (1+max 0 •) (h Leaf)
2 

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

Добиться дальнейшего увеличения производительности или возможностей для преобразования или анализа можно, изменив представление передаваемого продолжения. Рассмотрим этот прием на примере вычисления высоты дерева. Как уже говорилось, продолжения рекурсивного вызова относительно вызывающей процедуры в данном случае имеют вид 1+max • (height …) или 1+max … •, а продолжение вызова относительно вызова верхнего уровня имеет вид списка из таких элементов. Определим соответствующую структуру данных явно.

data Context α = -- heightCPS r (\hr -> k (1 + max <?> hr)) | KLeft {r ::Tree α, k::Context α} -- k (1 + max hl <?>) | KRight {hl::Int, k::Context α} | Id height t = heightS t Id where heightS Leaf k = interp k 0 heightS (Fork a l r) k = heightS l (KLeft r k) interp Id x = x interp (KLeft r k) hl = heightS r (KRight hl k) interp (KRight hl k) hr = interp k (1 + max hl hr)

Если внимательно проследить соответствие между этим фрагментом кода и предыдущим, то видно, что один получен из другого чисто механической трансляцией. Каждой из трех разновидностей замыканий (см. 4), использованных в качестве аргумента k в предыдущем фрагменте кода, сопоставлен свой конструктор типа Context, а аргументами являются свободные переменные этого замыкания. Использованная техника (дефункционализация) подробно описана в статье «Defunctionalization at work» Оливье Дэнви и Лассе Нильсена ([58]), а также в диссертации Нила Митчелла «Transformation and analysis of functional programs», один из разделов которой посвящен дефункционализатору firstify ([119], а также в статье «Losing functions without gaining data» [120], презентации [121]), и статье Митчелла Ванда «Continuation-based program transformation strategies» ([175]).



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

int sum(int a,int b) { for(int s = 0, i = a; i < b ; ++i) s += i; return s; }
sum a b = sum' 0 a where sum' s i = if i>=b then s else sum' (s+i) (i+1)

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

На самом деле в функциональных программах принято избегать рекурсии, а вместо этого абстрагировать ее в рекурсивные комбинаторы общего назначения, написанные раз и навсегда. Например, идиоматический вариант данной программы на Haskell выглядел бы так: mySum a b = sum [a..b] (где sum — встроенная функция в Haskell).

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

countWords s = space 0 s where space n [] = n space n (' ':rest) = space n rest space n (c:rest) = word (n+1) rest word n [] = n word n (' ':rest) = space n rest word n (c:rest) = word n rest

На рис. 2 приведена диаграмма состояний конечного автомата, соответствующего этой программе; сравните их.


Рис. 2: Конечный автомат для подсчета слов

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

off() -> receive on -> disconnected() end. disconnected() -> receive {ring, Someone} -> ringing(Someone); pickupReceiver -> dialing([]) off -> off() end. ringing(Who) -> receive pickupReceiver -> talking(Who); {abort, Someone} -> disconnected(); off -> off() end. dialing(Digits) -> receive {key, backspace} -> dialing(lists:tail(Digits)); {key, Digit} -> dialing([Digit|Digits]); hangUp -> disconnected(); off -> off() after ?DIAL_TIMEOUT -> connecting(lists:reverse(Digits)) end connecting(Digits) -> dialNumber(Digits), etc.

Возможна ситуация, когда заранее неизвестно, какая именно функция вызывается хвостовым вызовом (например, происходит вызов функции, хранившейся в переменной или переданной в качестве аргумента). В первую очередь речь идет о конечных автоматах с заранее неизвестным набором состояний и о стиле передачи продолжений (CPS). См. также обширную и интересную серию постов Джо Маршалла «You knew I’d say something» на тему хвостовых вызовов, их роли и отличия от обычной «хвостовой рекурсии» и циклов [113].

Кроме того, аналогичная ситуация (отсутствие информации о том, какая функция вызывается хвостовым вызовом) возникает и при вызове виртуального метода в программе на объектно-ориентированном языке! См. «Why object-oriended languages need tail calls» Гая Стила ([158]) и «Functional objects» Маттиаса Феллайзена ([63]). В этих двух источниках приводятся аргументы, почему объектно-ориентированным языкам особенно необходима эффективная поддержка хвостовых вызовов. На форуме Lambda the Ultimate есть обсуждение этих двух статей ([156]).

Рассмотрим иной пример: поиск самого первого элемента в дереве, удовлетворяющего предикату.

(define (with-first p tree with-found when-notfound) (define (search tree when-notfound) (define (search-right) (search (right tree) when-notfound)) ; (*) (if (empty? tree) (when-notfound) ; (*) (if (p tree) (with-found tree) ; (*) (search (left tree) search-right)))) ; (*) (search tree when-notfound))

Спецификация процедуры такова: Возвратить результат выполнения процедуры with-found над самым первым (в порядке обхода в глубину слева направо) элементом в дереве tree, удовлетворяющим предикату p; если же такого элемента нет, то возвратить результат выполнения процедуры when-notfound.

Идея решения состоит в том, что:

Это и написано в приведенном коде на Scheme. Код написан с использованием стиля передачи продолжений: процедуре search передается дерево и «продолжение неудачи».

Заметим, что вызовы, отмеченные «(*)» — хвостовые. Посмотрим, что следует из того, что язык Scheme поддерживает полноценную оптимизацию хвостовых вызовов.

Поддержка оптимизации хвостовых вызовов обладает одним недостатком: трудно сохранить отладочную информацию о стеке вызовов. Существуют различные способы борьбы с этим: см.,  например, «A tail-recursive machine with stack inspection» Джона Клементса и Маттиаса Феллайзена ([54]), а также по ссылкам из упомянутой серии постов Джо Маршалла [113].

Еще одно применение поддержки TCO — возможность реализации очень необычных структур управления через преобразование всего кода в CPS на этапе компиляции. В этом случае все без исключения вызовы становятся хвостовыми, однако программа очень сильно усложняется и замедляется; требуются дополнительные этапы обработки кода, чтобы восстановить производительность. Плюс этого подхода — становятся возможными такие нестандартные структуры управления, как, например, call/cc, shift/reset («ограниченные продолжения», delimited continuations) и другие, реализовать которые без CPS-преобразования намного труднее. call/cc имеет множество интересных применений, многие из которых описаны в статье «Call with current continuation patterns» Даррелла Фергюссона и Деуго Дуайта [64]; огромное количество информации об этой и других разновидностях продолжений содержится на странице-библиографии «Continuations and Continuation Passing Style» [5]. CPS-преобразование кода используется в некоторых реализациях Scheme в качестве промежуточного этапа компиляции.



Реализация
Впервые полноценная поддержка оптимизации хвостовых вызовов (далее TCO) была реализована в языке Scheme; более того, это требуется стандартом языка.

Как ни удивительно, стандарт Common Lisp не требует TCO, хотя ряд реализаций ее все же предоставляют. В похожем на Lisp языке Mathematica она отсутствует.

TCO реализована в OCaml, в Prolog (насколько это понятие там вообще применимо), в Haskell (опять же, насколько это понятие там применимо).

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

В языках Scala (смесь Java и ML) и Clojure (диалект Lisp), компилирующихся в код для JVM, поддержка TCO также отсутствует, однако были придуманы обходные решения, пригодные в определенных ситуациях. Эти решения обсуждаются в соответствующих списках рассылки: сначала произошло обсуждение в рассылке Clojure (ветка «Trampoline for mutual recursion» [75], начатая самим Ричем Хикки), которое спровоцировало дискуссию в рассылке Scala (ветка «Tail calls via trampolining and an explicit instruction», начатая Джеймсом Айри [90]).

Существуют следующие подходы к реализации TCO в языке (первые два из них были предложены именно в статье Гая Стила «Lambda: the Ultimate GOTO» [95]):



Имитация
Хвостовую рекурсию и взаимную рекурсию (т. е. ситуации, когда функция, чей вызов является хвостовым, известна статически) легко преобразовать в цикл. Схемы и примеры преобразований описаны в лекции 2 «Язык Scheme. Рекурсия и итерация» курса «Функциональное программирование» Евгения Кирпичёва [186] (также там описаны и некоторые другие приемы удаления или уменьшения кратности рекурсии).

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

Выше была описана еще одна техника — преобразование в CPS и последующая дефункционализация. Чтобы применить эту технику в императивном языке (где CPS-форма будет слишком трудна для представления), придется, скорее всего, временно переписать алгоритм на языке вроде Haskell, OCaml или Scheme, соптимизировать его и затем переписать результат на исходном языке.

8  Чисто функциональная структура данных
(Purely functional data structure)



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



История
В истории развития неизменяемых структур данных можно отметить следующие важные вехи:



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

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

Второй вариант, очевидно, никуда не годится: создавать независимую изменяемую копию целой позиции — слишком дорого. Первый вариант выглядит неплохо, но не поддается параллелизации, а ведь задача перебора игровых позиций — прекрасный кандидат для этого: анализ различных позиций можно проводить совершенно независимо. Проблема в том, что логика «makeMove / рекурсивный-вызов / undoMove» чисто последовательна по своей сути, и полагается на то, что очередной рекурсивный вызов начнется только после того, как завершится undoMove предыдущего. Если попытаться параллелизовать содержимое цикла, произойдет страшная путаница, как почти всегда происходит при попытке параллелизовать вычисления с изменяемыми структурами данных без строжайшего контроля и титанических усилий.

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

findBestMove(Position initial, int lookahead) { for(Move move : getPossibleMoves(initial)) { ...findBestMove(initial.withMove(move), lookahead-1)... } }

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



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



Пример: Односвязные списки (стеки)

Приведем реализацию чисто функционального стека на Java.

public class Stack<T> { private final T head; private final Stack<T> tail; Stack<T>(T h, Stack<T> t) { head=h; tail=t; } public static <T> Stack<T> empty() { return null; } public static <T> Stack<T> push(T t, Stack<T> s) { return new Stack<T>(t, s); } public static <T> T top(Stack<T> s) { if(s == null) throw new NoSuchElementException(); return s.head; } public static <T> Stack<T> pop(Stack<T> s) { if(s == null) throw new NoSuchElementException(); return s.tail; } }

Как видно, все стековые операции выполняются за время O(1) и не используют изменяемых данных. Такой стек может использоваться, к примеру, для эффективного хранения текущего стека вызовов в интерпретаторе языка или в библиотеке журналирования: при входе в процедуру он будет заменяться новым стеком, который длиннее на один элемент, а при выходе — на предыдущий стек. Во время процедуры можно получить текущее значение стека вызовов и куда-нибудь его при необходимости сохранить (оно останется в первозданном виде и не «испортится»). Например, это нужно затем, чтобы вывести в асинхронный лог отладочное сообщение (из-за асинхронности лога использование изменяемого стека недопустимо), или даже затем, чтобы вернуться в предыдущую точку программы. В качестве примера реального использования такого стека см. исходный код профайлера antro [180] для сборочных скриптов Ant. Данная структура данных также называется «спагетти-стек (spaghetti stack)»

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

Stack<String> s1 = empty(); Stack<String> s2 = push(push(s1, "a"), "b"); Stack<String> s3 = pop(s2); Stack<String> s4 = pop(s2); Stack<String> s5 = push(s4, "c");

Рис. 3: Диаграмма объектов к коду примера со стеками



Пример: Бинарные деревья
Большинство разновидностей бинарных деревьев поиска (используемых для представления множеств, словарей, приоритетных очередей и т. п.) также допускают чисто функциональную реализацию. В типичной ситуации результат выполнения какой-либо простой операции над деревом (скажем, вставка/удаление элемента) отличается от исходного дерева на относительно небольшое количество элементов, пропорциональное высоте дерева, т. е. «меняются» лишь элементы, расположенные неподалеку от пути к затронутой точке дерева. В качестве примера на рис. 4 на одной диаграмме объектов приведены два бинарных дерева поиска — a, образованное значениями 0, 1, 3, 4, 6, 8, 10, 11, 13, 14, и b, образованное добавлением к a значения 7. Как видно, большая часть узлов (например, все левое поддерево) у них общая, однако эти деревья могут быть использованы независимо — опять же, благодаря неизменяемости обоих деревьев.


Рис. 4: Чисто функциональные деревья

Существует множество различных чисто функциональных структур данных, основные из которых описаны в книге Окасаки ([127]). Нередко такие структуры используют ленивые вычисления, что затрудняет анализ производительности; в книге Окасаки акцентируется внимание на таком анализе.

Приведем, наконец, возможный интерфейс класса «чисто-функциональный словарь» в типичном императивном языке (обратите внимание на названия процедур «добавления» и «удаления»: в отличие от традиционных put(add)/remove, они названы не глаголами, обозначающими изменение, а союзами).

class ImmutableMap { ... int size() {...} boolean isEmpty() {...} Value get(Key k) {...} ImmutableMap with(Key k, Value v) {...} ImmutableMap without(Key k) {...} ... }



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

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

class ThreadSafeMutableMap { private AtomicReference<ImmutableMap> map; Value get(Key k) { return map.get().get(k); } void put(Key k, Value v) { ImmutableMap m; do { m = map.get(); } while(!map.compareAndSet(m, m.with(k,v))); } void remove(Key k) { ImmutableMap m; do { m = map.get(); } while(!map.compareAndSet(m, m.without(k))); } }

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

Так как при реализации чисто функциональных структур программист избавлен от проблем, связанных с изменяемым состоянием, то корректно реализовать и оттестировать такую структуру также проще, чем «обычную» (см. статью «Изменяемое состояние: опасности и борьба с ним» Евгения Кирпичёва [185]).

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



Реализация
В языке Java, C# и многих других разработчиками стандартных библиотек было принято решение сделать класс строки неизменяемым. Это позволило представлять строки как отрезки неизменяемого массива символов и, как следствие, разделять ссылку на такой массив между несколькими строками, что позволило реализовать очень эффективную операцию взятия подстроки, а также уничтожило как класс ошибки, связанные с неявным изменением строк, достаточно частые в таких языках, как C++. История показала, что это решение оказалось очень удачным и не наложило существенных ограничений на использование строк: в ситуациях, где необходима строка как изменяемая структура данных, используются другие классы (например, в Java используется StringBuilder), которые, однако, используются только для промежуточного представления текста, а не как основной класс строк. Такое же решение (отрезок разделяемого неизменяемого массива) используется в языке Erlang для представления «binaries» (байтовых массивов).

Стандартная библиотека языка Haskell по большей части содержит именно чисто функциональные структуры данных: к примеру, это стандартные списки (модуль Data.List), словари (Data.Map), множества (Data.Set), последовательности с быстрой конкатенацией (Data.Sequence) и другие.

Структуры, описанные в книге Окасаки [127], реализованы в его библиотеке «Edison» (существует на hackage [125]). Большое количество других структур данных можно найти на hackage ([20]) в разделе «Data Structures», хотя и не все из них чисто функциональные.

Стандартная библиотека языка Scala (пакет scala.collections.immutable) содержит несколько функциональных структур данных.

Стандартная библиотека языка Clojure содержит в основном чисто функциональные структуры данных и реализует таким образом даже хэш-словари и множества (с временем доступа порядка O(log32n)).

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

В библиотеке functionaljava ([23]) реализованы некоторые функциональные структуры, в частности, красно-черные деревья.



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

9  Алгебраический (индуктивный) тип данных
(Algebraic (inductive) datatype)



Суть
Тип данных, состоящий из нескольких различимых разновидностей (возможно, составных) термов (значений).



История
Среди языков программирования первым поддерживать простейший аналог алгебраических типов стал, судя по всему, Algol 68, в форме «united modes». Затем в форме «variant records» поддержка схожей и достаточно примитивной возможности появилась в Pascal и других языках. В конце 1970 гг. появился язык ML, поддерживавший современную форму алгебраических типов, включая рекурсивные и полиморфные алгебраические типы; эта же форма (с другим синтаксисом и с небольшими, но важными расширениями) используется в стандарте Haskell’98. ML положил начало другим статически типизированным функциональным языкам. Практически все они сейчас поддерживают алгебраические типы.

В большинстве языков процедурного и объектно-ориентированного программирования (наследниках Си и Smalltalk) их поддержка так и не появилась, и пользователи этих языков довольствуются различными имитациями или стараются избежать их. В некоторых динамических языках с богатыми возможностями метапрограммирования (Lisp, Ruby и т. п.) можно реализовать абстрактные типы данных, схожие по возможностям с алгебраическими типами, как, например, сделано в языке Scheme (пакет «struct.ss» [28]).

В 1992 г. появился язык Coq [7], а с ним и «исчисление индуктивных конструкций» (Calculus of Inductive Constructions, CIC) и чрезвычайно мощное и общее понятие индуктивного типа. Сама концепция алгебраических типов не развивалась существенным образом с момента появления индуктивных типов в CIC, однако находились новые применения некоторым их частным случаям. Так, в 2003 г. в статье «Guarded recursive datatype constructors» [178] была предложена несколько менее общая конструкция, ныне известная под названием «обобщенный алгебраический тип» (Generalized Algebraic Datatype (GADT)). Первым языком программирования общего назначения, в котором появилась полноценная поддержка обобщенных алгебраических типов, был Haskell (начиная с компилятора GHC 6.4, выпущенного в 2005 г.).



Интуиция
Посмотрим, как можно (скажем, для задачи определения столкновений в физическом симуляторе) определить тип «двумерная геометрическая фигура», а именно — прямоугольник, круг или треугольник.

Итак, существует 3 типа фигур:

enum ShapeType {RECTANGLE, CIRCLE, TRIANGLE};

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

struct Rectangle { Point topLeft, bottomRight; };

Круг — центром и радиусом:

struct Circle { Point center; double radius; };

Треугольник — тремя точками.

struct Triangle { Point a,b,c; };

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

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

Первый способ, приходящий на ум программисту, имеющему опыт разработки на Си, Java и т. п. таков: воспользоваться составной структурой данных, хранящей и форму, и параметры. К сожалению, почти все наиболее распространенные современные языки программирования допускают, в сущности, только один способ комбинирования двух элементов данных в одной сущности — помещение их в поля одной и той же структуры или класса. Но при таком способе нельзя учесть, что для формы CIRCLE осмыслен только набор параметров типа Circle, и т. п.

Другой способ заключается в том, чтобы отказаться от разделения на «форму» и «параметры», и описать тип данных явно и безызбыточно: «либо прямоугольник (задаваемый двумя точками), либо круг (задаваемый точкой и числом), либо треугольник (задаваемый тремя точками)». Вот код на Haskell:

data Shape = Rectangle {topLeft::Point, bottomRight::Point} | Circle {center::Point, radius::Double} | Triangle {a::Point, b::Point, c::Point}

Далее в статье мы будем в основном пользоваться несколько иной нотацией. В этой нотации тип Shape определяется не как набор способов сконструировать значение типа Shape, а как набор заявлений «Терм такого-то вида имеет тип Shape»; отличие довольно тонкое и в данном примере вовсе не проявляется, но оно станет ясно позднее.

data Shape where Rectangle {topLeft::Point, bottomRight::Point} :: Shape Circle {center::Point, radius::Double} :: Shape Triangle {a::Point, b::Point, c::Point} :: Shape

Например:

Circle {center=Point{x=1,y=2}, radius=0.5}::Shape -- Or, more briefly: Circle (Point 1 2) 0.5 :: Shape



Описание
Рассмотрим различные аспекты понятия алгебраического типа по отдельности.


Алгебраические типы позволяют определять типы-произведения (кортежи).

data FileInfo where FileInfo { name::String, accessRights::Int, lastModified::Date } :: FileInfo

Как видно, тип FileInfo задается утверждением «Если n — строка, ar — целое число, lm — дата, то FileInfo \{ name=n, accessRights=ar, lastModified=lm \} (сокращенно FileInfo n ar lm) имеет тип FileInfo». Такой тип соответствует обыкновенной «структуре» в языках семейства Си.

Под термином «тип-произведение» в данном случае имеется в виду, что тип FileInfo соответствует декартову произведению множеств, соответствующих его компонентам.


Алгебраические типы позволяют определять типы-суммы.

data Color where Red :: Color Orange :: Color Yellow :: Color Green :: Color LightBlue :: Color Blue :: Color Violet :: Color

Как видно, тип Color задается утверждениями «Red — цвет», «Orange — цвет», «Yellow — цвет» и т. п. Такой тип соответствует «перечислению» (enumeration) в языках семейства Си.

Под термином «тип-сумма» в данном случае имеется в виду, что множество термов, имеющих тип Color, складывается из термов вида «Red», «Green», «Blue», «Yellow».


Алгебраические типы позволяют определять типы вида «сумма произведений».

data Shape where Rectangle {topLeft::Point, bottomRight::Point} :: Shape Circle {center::Point, radius::Double} :: Shape Triangle {a::Point, b::Point, c::Point} :: Shape

Этот пример сочетает в себе две предыдущих возможности: тип Shape складывается из термов вида Rectangle ... ... и т. п.

Важно понимать, что Rectangle, Circle, Triangle сами по себе не являются типами в языке Haskell и в большинстве других языков с поддержкой алгебраических типов — это лишь подмножества значений типа Shape, так же как, скажем, чётные числа — подмножество значений типа int в Java. Фундаментальных теоретических препятствий для этого нет, однако если бы Rectangle был сам по себе типом, то значение Rectangle p1 p2 имело бы сразу два типа: Rectangle и Shape, что существенно затрудняло бы вывод типов (см. 2) и усложняло язык в целом.


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

data Document where Paragraph {text::String} :: Document Picture {picture::Image} :: Document Sequence {elements::[Document]} :: Document Table {rowHeadings::[String], colHeadings::[String], cells::[[Document]]} :: Document

Как видно, в этом примере тип Document задан так:

Таким образом, например, следующий терм — документ:

Sequence [Paragraph "hello", Picture (imageFromFile "earth.jpg")]

В этом примере тип Document определен через самого себя, т. е. рекурсивен.

data Picture where PictureFromFile {fileName::String} :: Picture AutoFigure {figure::Shape} :: Picture Overlay {layers::[Picture]} :: Picture EmbeddedDocument {document::Document} :: Picture

Картинка определена как «картинка из файла», «автофигура», «композиция нескольких картинок» или же «встроенный документ» (например, MS Office позволяет использовать большинство средств форматирования текста внутри текстовых элементов картинок). Как видно, типы Document и Picture определены друг через друга, т. е., взаимно рекурсивны.


Алгебраические типы могут быть параметризованы другими типами.

data Tree α where Leaf :: Tree α Fork {value::α, left::Tree α, right::Tree α} :: Tree α

В этом примере задан тип «Бинарное дерево со значениями типа α в узлах (сокращенно: дерево α)». Он задан так: «Leaf — дерево α (заметьте: независимо от α)», «если x::α, t1::Tree α, t2::Tree α, то Fork x t1 t2 — дерево α». Например, Leaf::Tree Int (пустое дерево является деревом со значениями типа Int (как, впрочем, и любого другого) в узлах), а также:

Fork "a" (Fork "b" Leaf Leaf) Leaf :: Tree String
data Hash k h v where Hash {hash::k->h, equal::k->k->Bool, table::Array h [(k,v)]}

В этом примере задан тип «Хэш-таблица из k в v с хэшами типа h». Если k,h,v — типы, f::k->h, e::k->k->Bool, t::Array h [(k,v)] (что означает: массив с индексами типа h и значениями типа «список пар (k,v)»), то Hash f e t — хэш-таблица из k в v с хэшами типа h.

Интуитивно хочется избавиться от упоминания типа h в типе Hash k h v: он не имеет отношения к интерфейсу хэш-таблицы как отображения из ключа в значение. Вскоре мы увидим, что это возможно.


Алгебраические типы допускают неоднородную рекурсию: аргумент рекурсивного применения конструктора типа может быть сложным.

data RAList α where Nil :: RAList α ZeroCons :: RAList (α,alpha) -> RAList α OneCons :: α -> RAList (α,alpha) -> RAList α

Здесь определен хитроумный способ задания структуры данных «список с произвольным доступом (random-access list)» при помощи так называемых «вложенных», или «полиморфно рекурсивных» типов. За алгоритмическими подробностями предлагается обратиться в книгу Криса Окасаки «Purely functional data structures» ([127]) и презентацию Евгения Кирпичёва по теме книги ([182], слайды 37—39).

Как видно, конструктор OneCons, конструирующий терм типа RAList α, имеет одним из аргументов значение типа RAList (α,alpha). Например, OneCons 5 (ZeroCons (OneCons ((6,2),(4,3)) Nil)) :: RAList Int. Применения этой разновидности алгебраических типов см. в секции «Применение».


Конструкторы алгебраического типа T могут упоминать не только те типовые переменные, по которым параметризован тип T.

data Hash k v where Hash {hash::k->h, equal::k->k->Bool, table::Array h [(k,v)]}

В этом примере задан тип «Хэш-таблица из k в v». Если k,h,v — типы, f::k->h, e::k->k->Bool, t::Array h [(k,v)], то Hash f e t — хэш-таблица из k в v. Обратите внимание на разницу между этим объяснением и объяснением, данным выше: она заключается в том и только том, что из типа хэш-таблицы убрано упоминание о внутренне используемом ею типе хэшей. Таким образом, имея значение типа Hash k v, уже невозможно узнать, какой именно тип эта таблица использует для хэшей, однако же известно, что какой-то — использует, и если обозначить этот тип за h, то поле hash этой таблицы будет иметь тип k -> h и т. п. Это позволяет реализовать все операции над хэш-таблицей без знания конкретного типа h.

Такой тип называется «экзистенциальным (existential)», поскольку множество его значений составляют термы, квантифицированные квантором существования:

  Hash k v = 

Hash f e t || ∃ hf::k → he::k → k to Boolt::Array h [(k,v)]

Экзистенциальные типы играют огромную роль при реализации абстрактных типов данных, поскольку, в общем-то, и являются воплощением абстракции как процесса «забывания» чего-то конкретного (в данном случае — конкретного типа h). В этом смысле они тесно связаны с понятием инкапсуляции в ООП. Хороший обзор применения экзистенциальных типов для реализации абстрактных типов можно найти в первой половине статьи Мартина Одерски и Константина Лойфера «Polymorphic type inference and abstract data types » ([107]). В особенности интересными и полезными экзистенциальные типы становятся в комбинации с классами типов (см. 13); см. статью Лойфера «Type classes with existential types» [106]. Также см. страницы «Existential type» [30] и «OOP vs type classes» [31] в Haskell wiki.


Типы результатов веток в определении параметризованного алгебраического типа могут различаться.

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

data Expr α where Const :: α -> Expr α Add :: Expr Int -> Expr Int -> Expr Int Equal :: (Eq α) => Expr α -> Expr α -> Expr Bool IfThenElse :: Expr Bool -> Expr α -> Expr α -> Expr α

Например:

IfThenElse (Equal (Const 1) (Add (Const 2) (Const 3))) (Const False) (Const True) :: Expr Bool

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

interp :: Expr α -> α interp (Const x) = x interp (Add e1 e2) = interp e1 + interp e2 interp (Equal e1 e2) = interp e1 == interp e2 interp (IfThenElse c t e) = if interp c then interp t else interp e

Тогда вычисление описанного выражения interp (IfThenElse ...) даст True.

Специфика данного примера, как видно, в том, что типовый параметр α конструируемого терма у разных конструкторов был разным: у Add — Int, у Equal — Bool, у Const он был полиморфен, а у Equal — даже ограниченно полиморфен по типам, принадлежащим к классу (см. 13) Eq.

Большинство примеров, использующих данную возможность алгебраических типов, также реализуют тот или иной «язык» (с другой стороны, любая структура данных — своего рода язык). Это существенно увеличивает возможности системы типов по обеспечению корректности (иначе очень трудно определить алгебраический тип Expr, гарантируя, что значения типа Expr a не имеют ошибок типизации, например IfThenElse (Const 1) ....

Довольно часто (в т. ч. и в стандарте Haskell’98) под термином «алгебраический тип» понимается несколько более узкое понятие: а именно, из рассмотренных возможностей исключаются две последних, поскольку они существенно усложняют реализацию языка. В этом случае становится возможным записать алгебраический тип в упрощенном синтаксисе (именно он используется в Haskell’98). Кроме того, поля конструкторов не обязаны быть именованными:

data Tree α = Leaf | Fork {value::α, left::Tree α, right::Tree α} data Tree α = Leaf | Fork α (Tree α) (Tree α)

Типы же, использующие эти возможности, называются обобщенными алгебраическими (Generalized Algebraic Data Type, GADT).

Возможностями GADT разнообразие и мощь концепции алгебраического типа не исчерпывается: в языке Coq, поддерживающем т. н. зависимые типы, алгебраические типы называются «индуктивными» (используется система типов «Calculus of Inductive Constructions» — она хорошо описана в книге Ива Берто и Пьера Кастеррана о системе Coq «Coq’art: Interactive theorem proving and program development» [40], а формально задана в статье Кристины Паулин-Моринг «Inductive definitions in the system Coq — Rules and properties» [131]) и обладают фактически неограниченной выразительной мощью, позволяя в т. ч. выражать сколь угодно сложные классы структур данных с инвариантами. Мы не будем подробно останавливаться на них в этой статье — краткий обзор приведен в презентации Евгения Кирпичёва [181], а более полный можно найти в источниках, указанных в ней.

Для всякого алгебраического типа определена чрезвычайно общая операция свертки (см. 11), абстрагирующая способ вычисления «по индукции» (снизу вверх) над значениями такого типа. Предлагается обратиться за разъяснениями к соответствующей подстатье. Для обычных алгебраических типов составление процедуры свертки тривиально. Кроме того, существуют обобщения понятия свертки и на вложенные и обобщенные алгебраические типы; их автоматическое порождение реализовано в языке Coq под названием «принципов индукции» (induction principles).

У алгебраических типов есть и недостатки:



Использование
«Обычные» (не обобщенные) алгебраические типы используются в поддерживающих их языках (Haskell, OCaml, F#, Scala и т. п.) повсюду: например, в Haskell все определяемые пользователем типы данных — алгебраические; вообще говоря, алгебраические типы намного лучше подходят для описания структур данных и позволяют намного естесственнее записывать алгоритмы их обработки (при помощи сопоставления с образцом (см. 10)), чем структуры (записи) или объекты. Хотя чаще других используются «тривиальные» алгебраические типы, которые могли бы быть записаны при помощи структуры или перечисления, но примеры, когда тривиальным типом не обойтись, имеются в изобилии. Приведем несколько совершенно произвольных отрывков из библиотек языка Haskell, опуская тривиальные типы.

Определение типа JSON-выражений из библиотеки json [65]:

data JSValue = JSNull | JSBool Bool | JSRational Bool Rational | JSString JSString | JSArray [JSValue] | JSObject (JSObject JSValue)

Определение типа SQL-значений из библиотеки HDBC [72]:

data SqlValue = SqlString String | SqlByteString ByteString | SqlWord32 Word32 ... | SqlDouble Double | SqlLocalTimeOfDay TimeOfDay | SqlZonedLocalTimeOfDay TimeOfDay TimeZone ... | SqlTimeDiff Integer | SqlNull

Определение типа «Игровой объект» из игры Monadius [166] (фрагмент):

data GameObject = VicViper{ -- player's fighter. ... speed :: Double, powerUpPointer :: Int, ...} -- missile that fly along the terrain | StandardMissile{ ... position :: Complex Double,hitDisp :: Shape, probe :: GameObject,... } | PowerUpCapsule { ... position :: Complex Double,hitDisp :: Shape, hp :: Int,age :: Int } | ... | ScrambleHatch{ ... position :: Complex Double,gateAngle :: Double, gravity :: Complex Double,hitDisp :: Shape,hp :: Int, age :: Int,launchProgram :: [[GameObject]]} | ...

Абстрактные типы используются вместо алгебраических очень часто: например, в стандартном модуле словарей языка Haskell Data.Map (документация: [26]), в модуле приоритетных очередей heap [10], в библиотеке «идеального хэширования» PerfectHash [15], обильно используются в модуле-привязке к физическому движку «Hipmunk» [11], в библиотеке для построения графиков Chart [3] (например, в модуле Grid) и т. д.

Один из плюсов использования абстрактных типов — возможность создания «умных» конструкторов и хранения рядом со значениями дополнительных данных. Например, в Chart тип Grid (табличная укладка экранных элементов), оператор «обертывание в единичную ячейку» и оператор «один над другим» определены так:

type Size = (Int,Int) type SpaceWeight = (Double,Double) data Grid α = Value (α,Span,SpaceWeight) | Above (Grid α) (Grid α) Size | Beside (Grid α) (Grid α) Size | Overlay (Grid α) (Grid α) Size | Empty | Null -- There are also functions 'width','height' that extract -- the corresponding field from a Grid's Size. tval a = Value (a,(1,1),(0,0)) above Null t = t above t Null = t above t1 t2 = Above t1 t2 size where size = (max (width t1) (width t2), height t1 + height t2)

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

Вложенные типы данных и полиморфная рекурсия в основном применяются для задания структур данных со сложными инвариантами. Они используются во многих структурах данных в фундаментальном труде Криса Окасаки «Purely functional data structures» [127]. Одна из таких структур описана в статье того же автора «Binomial queues as a nested type» ([124]). Еще один экзотический, но интересный пример — циклические структуры данных, позволяющие при реализации операций над ними учитывать зацикленность в явном виде (статья «Representing cyclic structures as nested datatypes» [69]).

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



Реализация
Алгебраические типы реализованы в различных формах во множестве языков:



Имитация
Задачи, решаемые алгебраическими типами, возникают на практике постоянно. В программистском фольклоре существует множество (неудобных) способов их решения без поддержки АТД. Вот некоторые из них.


Использование одной структуры/класса с обнуляемыми полями.

Например:

enum exp_type { et_const_int, et_const_bool, et_binop, et_unop }; enum op_type { op_plus, op_minus, op_mul, op_div, op_unaryminus, op_unarynot }; struct expression { exp_type type; op_type operation; // If type = binop or unop. // If type = unop, only op_unary_* is allowed void *const_value; // If type = et_const_int or et_const_bool struct expression *op1; // If type = binop or unop struct expression *op2; // If type = binop };

Этот способ особенно часто используется в языках типа Java или C#, не поддерживающих конструкцию union. Недостатки очевидны:

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


Использование одной структуры/класса с union.

struct expression { exp_type type; union { struct { // If type = unop op_type operation; struct expression *operand; } unop; struct { // If type = binop op_type operation; struct expression *operand1; struct expression *operand2; } binop; int int_value; // If type = const_int bool bool_value; // If type = const_bool } data; };

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


Неявный union.

struct expression { exp_type type; }; struct unop { op_type operation; struct expression *operand; }; struct binop { op_type operation; struct expression *operand1; struct expression *operand2; }; int interpret(struct expression *e) { switch(e->type) { case et_const_int: return *(int*)(e+1); case et_unop: struct expression *operand = ((struct unop*)(e+1))->operand; ... } }

Здесь подразумевается, что если type==et_unop, то в памяти непосредственно после type расположена структура типа unop, и т. п.

Этот подход довольно часто используется, например, в WinAPI, в частности, в Security API ([2]). Из его достоинств можно назвать оптимальное использование памяти, из недостатков — по уродливости кода, трудоемкости и подверженности ошибкам он многократно превосходит оба предыдущих способа вместе взятых.


Объектно-ориентированная имитация при помощи наследования и диспетчеризации по типу

enum ExpressionType {...} abstract class Expression { ExpressionType type; Expression(ExpressionType t) {type=t;} } class Unop extends Expression { UnaryOperation op; Expression operand; Unop(UnaryOperation op,Expression operand) { super(ExpressionType.UNOP); this.op=op; this.operand=operand; } } class Binop extends Expression {...} ... class ExpressionUtils { Object eval(Expression exp) { switch(exp.type) { case ExpressionType.UNOP: ... .. } } }

При этом подходе для алгебраического типа создается базовый абстрактный класс, а для каждого конструктора создается по наследнику. Обработка значений такого типа осуществляется либо через операторы проверки и приведения типов ( expr instanceof Unop, (Unop)expr), либо при помощи приема, описанного далее. Минусы этого подхода: а) по-прежнему отсутствует проверка полноты разбора случаев; б) код, написанный в стиле ExpressionUtils.eval и выполняющий диспетчеризацию по типу или полю, имитирующему тип, в ООП считается антипаттерном и его рекомендуется заменять на использование виртуальных функций, паттерна Visitor и т. п. Использование виртуальных функций в классе Expression в такой ситуации не всегда оправдано, т. к. на этапе его проектирования может быть неизвестно, какие именно операции понадобятся. Обильное использование такой техники может легко привести к замусориванию класса разнородным кодом. Отчасти решает эту проблему паттерн Visitor, описанный в классической книге о паттернах проектирования ([68]), предназначенный для реализации двойной диспетчеризации.


Кодировка Чёрча и паттерн Visitor

interface ExpressionVisitor<T> { T visitConstInt(Integer value); T visitConstBool(Boolean value); T visitUnop(UnaryOperation op, Expression operand); // Or: T visitUnop(Unop expr); T visitBinop(BinaryOperation op, Expression rand1, Expression rand2); // Or: T visitBinop(Binop expr); } abstract class Expression { public abstract <T> T accept(ExpressionVisitor<T> v); } class Unop { UnaryOperation op; Expression operand; public <T> T accept(ExpressionVisitor<T> v) { return v.visitUnop(op, operand); } } ...

Паттерн Visitor подробно описан в книге о шаблонах проектирования ([68]) и в огромном количестве других источников, в основном в варианте, приведенном в комментариях («Or:...»).

Раскомментированный вариант visitUnop и visitBinop — частный случай кодировки Чёрча (см. статью о ней в Wikipedia [4], и главы об алгебраических типах в книге «Design concepts in programming languages» [169], см. также статью Янсена, Коопмана и Пласмейера «Efficient interpretation by transforming data types and patterns to functions» [91], где реализуется небольшой язык программирования с поддержкой алгебраических типов и сопоставления с образцом при помощи кодировки Чёрча, и эта реализация оказывается чрезвычайно эффективной). Этот прием позволяет представлять структуры данных и сопоставление с образцом при помощи одних лишь функций. В блог-посте «Structural pattern matching in Java» Оли Рунара [129] можно найти пример кода на Java, иллюстрирующего представление структур данных в кодировке Чёрча. Помимо имитации алгебраических типов кодировка Чёрча иногда используется и для их реализации, в качестве промежуточной стадии компиляции или оптимизации (такое применение описано в книге «Design concepts in programming languages»).


Использование абстрактного типа данных

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

10  Сопоставление с образцом
(Pattern matching)



Суть
Сопоставление формы структуры данных с формой образца и заполнение переменных-«дырок» в образце значениями в соответствующих местах структуры данных.



История
Первые языки с поддержкой сопоставления с образцом появились в 1960—1970-е гг; самым первым из них был SNOBOL — язык для обработки текста. Он предоставлял чрезвычайно богатые возможности по манипулированию образцами, а также позволял обращаться с ними как с объектами первого класса. Однако SNOBOL — очень экзотический язык. Согласно книге Саймона Пейтона-Джонса «Implementation of functional programming languages» [137], из более современных языков «первопроходцами» были ISWIM (абстрактный язык без реализации, представленный Петром Ландиным в его знаменитой статье «The Next 700 Programming Languages» [105]), SASL, NPL (далее эволюционировавший в Hope — предшественника Miranda и Haskell). В развитие технологии сопоставления с образцом внесли свой вклад такие языки, как РЕФАЛ (1968), Prolog (1972) и Mathematica (1988).

В конце 1980-х появились различные вариации на тему «представлений» (views), позволяющих решить некоторые проблемы расширяемости кода с использованием алгебраических типов, повысить его уровень абстракции и близости к предметной области, и т.д (см. конец секции «Описание»). Так, в 1987 г. вышла статья Филипа Вадлера на эту тему «Views: a way for pattern matching to cohabit with data abstraction» ([172]). Более сложная, но и более удобная в использовании (на взгляд автора) идея «активных образцов» нашла реализацию в работе «F# active patterns» Дона Сайма ([165], 2007 г). Возможность первоклассного манипулирования образцами в языке без встроенной поддержки такой возможности была предложена в 2000 г. Марком Таллсеном в статье «First class patterns» ([168]) и развита до удобного в использовании уровня совсем недавно — в 2008 году (Мортен Райгер, статья «Type-safe pattern combinators» [147]).



Интуиция


Рис. 5: Обращение списка

На рис. 5 графически декларативно записан алгоритм вычисления обращения списка, сведенный к задаче «приписать обращение списка xs к списку r» (изначально полагается r=[]). Алгоритм reverse' последовательно «откусывает» от списка голову и приписывает ее к результату. В алгоритме разбираются два случая, которые вместе описывают все множество входных списков:

Обратим внимание, что оба случая сопоставляют фактическую форму (структуру) списка xs с шаблоном: «пустой список» и «список с какой-то головой и каким-то хвостом», вместо того, чтобы явно вычислять голову и хвост списка при помощи соответствующих операций доступа.

На Haskell описанная функция будет выглядеть так:

reverse xs = reverse' xs [] where reverse' xs r = case xs of [] -> r (h:t) -> reverse' t (h:r)

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

Для сравнения, вот реализация, не использующая в явной форме сопоставление с образцом:

reverse xs = reverse' xs [] where reverse' xs r = if (null xs) then r else reverse' (tail xs) (head xs:r)



Описание
Оператор сопоставления с образцом, с точностью до синтаксиса конкретного языка, выглядит так:

case EXPRESSION of PATTERN1 -> VALUE1 PATTERN2 -> VALUE2 ...

Здесь EXPRESSION — произвольное выражение, обладающее значением (обычно принадлежащим к алгебраическому типу (см. 9)), VALUE — выражения или операторы (statement), а PATTERN — собственно образцы. Пары PATTERN -> VALUE называются уравнениями (clause), иногда — «клозами».

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

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


Рис. 6: Правый поворот дерева

Вот соответствующий код на Haskell:

data Tree α = Leaf α | Fork α (Tree α) (Tree α) rotateR tree = case tree of Fork q (Fork p a b) c -> Fork p a (Fork q b c)

Неформальная семантика case-выражения такова: «Значением выражения case E of P1 -> V1; ...; Pn -> Vn является значение правой части Vi первого из уравнений Pi → Vi, такого, что E сопоставимо с Pi».

В некоторых языках case-оператор — не единственная форма сопоставления с образцом. Например, Haskell позволяет непосредственно определять функции в таком стиле:

rotateR (Fork q (Fork p a b) c) = Fork p a (Fork q b c)

Или (перепишем пример reverse):

reverse xs = reverse' xs [] where reverse' [] r = r reverse' (h:t) r = reverse' t (h:r)

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

zip xs [] = [] zip [] xs = [] zip (x:xs) (y:ys) = (x,y):zip xs ys > zip [1,2,3] [4,5,6] [(1,4),(2,5),(3,6)]

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

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

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

filter p [] = [] filter p (x:xs) | p x = x : filter p xs | True = filter p xs

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

> Elem[x_, {}] := False; > Elem[x_, {x_, ys___}] := True; > Elem[x_, {_, ys___}] := Elem[x, {ys}]; > Elem[1, {2,2,1,4}] True

Такие образцы называются нелинейными (а остальные — т. е. упоминающие каждую метапеременную не более 1 раза — соответственно, линейными)

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

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

Абстрактные типы данных обеспечивают легкость поддержки и развития кода, а также модульность, за счет того, что доступ к данным определен исключительно в терминах поддерживаемых операций и их свойств. Например, абстрактный тип данных «Двоичное дерево» может быть определен в терминах операций «Получить данные узла, получить левое поддерево, получить правое поддерево». При таком интерфейсе возможно, к примеру, поменять представление дерева с «обычного» (data Tree a = Leaf a | Fork a (Tree a) (Tree a)) на компактное представление в массиве (где левым ребенком k-го узла является 2k+1-й, а правым — 2k+2-й), ничего не меняя в клиентском коде.

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

У этой проблемы существует несколько похожих решений. Первое из них появилось под названием «views (view patterns)» («представления»; описаны в статье Вадлера «Views: a way for pattern matching to cohabit with data abstraction» [172]); со временем в Haskell стало использоваться под тем же названием несколько другое решение; через несколько лет похожие техники появились в F# («active patterns») и Scala («extractors»). Оказалось, что они не только решают проблему смены представления данных, но и позволяют писать очень красивый и компактный код во многих других случаях.

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

Рассмотрим пример: будем сопоставлять строки с форматом дат (приведен псевдокод в предположении, что существует функция parseDate).

data Date = Date {year::Int, month::Month, day::Int, hours::Int, minutes::Int, seconds::Int} toUnixMillis :: String -> Integer toUnixMillis s = case (parseDate "YYYY/MM/DD hh:mi:ss" s) of Date yyyy mm dd hh mi ss -> ss + 60*mi + 3600*hh + ...

В этом коде написано: «Если parseDate ... s является датой с полями yyyy,mm,dd,hh,mi,ss, то ответ такой-то».

Почему бы не сказать «Если str является представлением даты с полями yyyy,mm,dd,hh,mi,ss относительно формата YYYY/MM/DD hh:mi:ss, то ответ такой-то»? Вот как такая переформулировка будет выглядеть на Haskell с использованием расширения языка «ViewPatterns»:

toUnixMillis (parseDate "YYYY/MM/DD hh:mi:ss" -> Date yyyy mm dd hh mi ss) = ...

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

> let swap (regex "([0-9]+)-([0-9]+)" -> [a,b]) = show b ++ "-" ++ show a > swap "123-4567" "4567-123"

В этих двух случаях нет большой разницы между кодом с представлениями и его аналогом с явным case, однако она становится очень явной, когда речь идет о сопоставлении одновременно нескольких значений с несколькими образцами. Например, вот гипотетический код для слияния двух приоритетных очередей (предполагается, что uncons q возвращает минимальный элемент q и остаток q). Можно представить себе, во что этот код превратится, если избавиться от «view patterns» и сделать case явным.

merge (isEmpty -> True) q = toList q merge q (isEmpty -> True) = toList q merge p@(uncons -> (x,xs)) q@(uncons -> (y,ys)) | x < y = x : merge xs q | True = y : merge p ys

Очень поучительное и красивое применение алгоритмов с использованием очередей и с использованием представлений именно для абстрагирования от конкретной реализации структуры (сопоставление с образцом-представлением «Очередь с головой h и остатком t») можно найти в статье Криса Окасаки «Breadth-first numbering: lessons from a small exercise in algorithm design» ([128]).

Реализация представлений в Haskell интересна именно своей неожиданной простотой и понятностью. Однако, средства F# синтаксически чуть удобнее. Множество красивых и убедительных примеров представлений в F# приведены в статье («F# active patterns» Дона Сайма [165]).



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

Объединение сортированных списков:

union xxs@(x:xs) yys@(y:ys) = case compare x y of LT -> x : mergeUnion xs yys EQ -> x : mergeUnion xs ys GT -> y : mergeUnion xxs ys union xs [] = xs union [] ys = ys

Посылка команды в протоколе POP3 (библиотека HaskellNet [122]; отрывок):

sendCommand (POP3C conn _) (RETR msg) = bsPutCrLf conn (BS.pack ("RETR " ++ show msg)) >> responseML conn sendCommand (POP3C conn _) (TOP msg n) = bsPutCrLf conn (BS.pack ("TOP " ++ show msg ++ " " ++ show n)) >> responseML conn sendCommand (POP3C conn _) (AUTH LOGIN user pass) = do bsPutCrLf conn (BS.pack "AUTH LOGIN") bsGetLine conn bsPutCrLf conn (BS.pack userB64) bsGetLine conn bsPutCrLf conn (BS.pack passB64) response conn where (userB64, passB64) = A.login user pass

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

balance :: Color -> Tree α β -> (α,β) -> Tree α β -> Tree α β balance B (T R (T R a x b) y c) z d = T R (T B a x b) y (T B c z d) balance B (T R a x (T R b y c)) z d = T R (T B a x b) y (T B c z d) balance B a x (T R (T R b y c) z d) = T R (T B a x b) y (T B c z d) balance B a x (T R b y (T R c z d)) = T R (T B a x b) y (T B c z d) balance color a x b = T color a x b

В языке Haskell сопоставление с образцом — не просто форма определения функций или записи условного выражения case, а основная форма связывания значений с именами, употребляемая во всех контекстах, где требуется такое связывание. Например, в генераторах списков и в do-блоках (первый пример учебный, второй взят из библиотеки HSH для Haskell [73], реализующей встроенный язык, схожий с конвейером shell):

presentValues map keys = [v | k <- keys, Just v <- [lookup map k]] fdInvoke (PipeCommand cmd1 cmd2) env ichan = do (chan1, res1) <- fdInvoke cmd1 env ichan (chan2, res2) <- fdInvoke cmd2 env chan1 return (chan2, res1 ++ res2)

В Mathematica многие функции определяются с помощью сопоставления с образцом. Вообще, большая часть всей обработки данных там происходит через переписывание согласно образцам. Рассмотрим несколько примеров: вычисление детерминанта матрицы 2 × 2, упрощение выражения согласно правилам log(x y)=log x+log y и log(xk)=k log x (Mathematica не производит такое упрощение самостоятельно, поскольку эти правила верны лишь для положительных аргументов), а также «устранение карринга (см. 5)».

> det[{{a_,b_}, {c_,d_}}] := a*d - b*c; > det[{{1,2}, {3,4}}] -2 > rules = {Log[x_ y_] :> Log[x] + Log[y], Log[x_^k_] :> k Log[x]}; > Log[Sqrt[a (b c^d)^e]] //. rules 1/2 (Log[a] + e (Log[b] + d Log[c])) > f[a][b][c][d] //. g_[x_][y__] :> g[x, y] f[a,b,c,d]



Реализация
Сопоставление с образцом реализовано во многих языках. В первую очередь это языки семейства ML: Haskell, OCaml, F# и т. п.

В Mathematica вся концепция вычислений основана на сопоставлении с образцом; фактически, движок Mathematica — лишь чрезвычайно эффективная машина для выполнения поиска и замены на основе образцов.

Сопоставление с образцом может быть реализовано и в качестве «макроса» при отсутствии непосредственной поддержки в языке. Так, например, сделано в реализации PLT Scheme (модуль «match.ss» [24]):

(require (lib "match.ss")) (define-struct plus (a b)) (define-struct times (a b)) (define add make-plus) (define mul make-times) (define expand (match-lambda (($ times a ($ plus b c)) (add (simplify (mul a b)) (simplify (mul a c)))) ...)

Здесь определен абстрактный тип данных «выражение» — две структуры «сумма» и «произведение», и приведен отрывок функции, выполняющей «раскрытие скобок» по правилу a (b + c) = a b + a c.

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

app([], Ys, Ys). app([X|Xs], Ys, [X|Zs]) :- app(Xs, Ys, Zs). ?- app([1,2],[3,4], Zs). Zs = [1,2,3,4]. ?- app(Xs,Ys,[1,2,3]). Xs = [], Ys = [1, 2, 3] ; Xs = [1], Ys = [2, 3] ; Xs = [1, 2], Ys = [3] ; Xs = [1, 2, 3], Ys = [] ; false.

В первом случае выражение app([1,2],[3,4],Zs) было сопоставлено с уравнениями app и обнаружилось, что это выражение подходит только под второй из них: X=1,Xs=[2],Ys=[3,4],Zs=[1|Zs'], при условии, что верно app([2],[3,4],Zs'). Это выражение также сопоставилось со вторым уравнением, а оставшееся выражение app([],[3,4],Zs'') сопоставилось с первым уравнением подстановкой Ys=[3,4],Zs''=Ys. Таким образом, получилось, что Zs=[1,2,3,4].

Во втором случае процесс протекал наоборот: выражение app(Xs,Ys,[1,2,3]) сопоставилось с обоими уравнениями; в результате сопоставления с первым было порождено решение Xs=[],Ys=[1,2,3], и т. д.

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

Еще один язык, основанный целиком на сопоставлении с образцом — язык РЕФАЛ (Refal). Отличительная особенность его — в том, что основная структура данных в нем — двусвязный список, что позволяет выполнять сопоставление с более сложными образцами и делает РЕФАЛ удобным для сложных задач обработки текста или деревьев, например, XML. Вот пример программы на РЕФАЛе, проверяющей, является ли строка палиндромом, по правилам: строка вида a S a  — палиндром, если S — палиндром; строка из одного символа — палиндром; пустая строка — палиндром; остальные строки — не палиндромы.

Palindrome { s.1 e.2 s.1 = <Palindrome e.2> ; s.1 = True ; = True; e.1 = False ; }



Имитация
Сопоставление с образцом всегда можно заменить на ручной разбор случаев с использованием операций доступа к данным; при этом придется проделать работу компилятора по поиску оптимального дерева проверок и код, скорее всего, увеличится в несколько раз. Кроме того, компиляция сопоставления с образцом далеко не тривиальна, и реализованные вручную проверки, скорее всего, будут неоптимальны. Например, при компиляции сопоставления с образцом для типа с 20 конструкторами компилятор, скорее всего, сгенерирует вовсе не последовательность из двадцати if..else, а бинарное дерево проверок логарифмической высоты.

В объектно-ориентированном языке сопоставление с образцом по одному аргументу алгебраического типа может быть частично реализовано при помощи введения виртуальных функций в классе, имитирующем этот тип. Для сопоставления с образцом по двум аргументам (двойная диспетчеризация) используется, например, паттерн Visitor. См. также секцию «Имитация» в статье об алгебраических типах (см. 9).

В языках с макросами зачастую можно реализовать сопоставление с образцом без «родной» поддержки языка, как сделано, к примеру, в языке PLT Scheme (см. выше).

Сравнительно недавно появились техники («First class patterns» Марка Таллсена [168] и «Type-safe pattern combinators» Мортена Райгера [147]), позволяющие манипулировать образцами как первоклассными объектами, и, как следствие, позволяющие в том числе выражать образцы в виде обычных функций высшего порядка (см. 3) и писать определения в стиле сопоставления с образцом без помощи синтаксической поддержки языка.

Также недавно появилась удобная и мощная библиотека для работы с первоклассными образцами на Haskell [144] (см. также серию блог-постов Райнера Поупа об этой библиотеке, в блоге по тегу «pattern combinators» [145]). Приведем краткий пример кода с ее использованием:

test1 :: Either Int (Int, Int) -> Int test1 a = match a ( left (cst 4) ->> 0 ||| left var ->> id ||| right (pair var var) ->> (+))

Этот код эквивалентен следующему:

test1 :: Either Int (Int, Int) -> Int test1 a = case a of Left 4 -> 0 Left x -> x Right (x,y) -> x+y

Основные преимущества первоклассных образцов — возможность отслеживать несовпадение значения с образцом во время выполнения (в этой ситуации при обычном сопоставлении произошла бы ошибка выполнения), возможность «дополнять» существующий образец новыми уравнениями и возможность определять принципиально новые разновидности образцов. При помощи первоклассных образцов можно, к примеру, реализовать «selective receive» (выборку сообщений по образцу из очереди процесса) в стиле Erlang. Это одна из мощных и уникальных возможностей Erlang.

11  Свёртка
(Fold)



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



История
Как утверждается в статье Грэма Хаттона «A tutorial on the universality and expressiveness of fold» ([88]), понятие свертки появилось в теории рекурсии в 1952 г. Свертки были впервые использованы в языке программирования APL (1962) для обработки списков. В 1978 г. свертки были упомянуты в работе Джона Хьюза «Why Functional Programming Matters» [84].

К произвольным структурам данных свертку впервые применил Г. Малкольм в 1990 г. в статье «Algebraic data types and program transformation» ([111]), обобщив идею свертки над списками. Эта идея получила развитие в очень известной статье Эрика Мейера, Маартена Фоккинги и Росса Патерсона «Functional programming with bananas, lenses, envelopes and barbed wire» ([117]) и многих других работах, где используется т. н. «универсальное свойство» свертки (описанное в вышеупомянутой статье Грэма Хаттона [88]). В 1998 г. в статье Ричарда Бёрда и Ламберта Меертенса «Nested datatypes» ([41]) было описано обобщение свертки на «вложенные» (полиморфно рекурсивные) алгебраические типы, а в статье Ральфа Хинзе «Efficient generalized folds» ([77]) и статье Джереми Гиббонса «Disciplined, efficient, generalized folds for nested datatypes» ([53]) это обобщение было улучшено.

Однако, шестью годами раньше, в 1992 г. появился язык Coq, исчисление индуктивных конструкций. В нём было разработано обобщение понятия свертки в качестве принципа индукции на значительно более сложный класс индуктивных типов (см. книгу «Coq’art: Interactive theorem proving and program development» [40] и статью «Inductive definitions in the system Coq: Rules and properties» [131])! Удивительно, что этот результат оставался незамеченным целых 6 лет: на первый взгляд некоторые определения сверток, предложенные, скажем, в «Nested datatypes» Бёрда и Меертенса [41], практически совпадают с теми, что автоматически генерирует Coq.



Интуиция
Рассмотрим несколько возможных операций над документами (возьмем для примера HTML):

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

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



Описание
Пусть T — рекурсивно определенный алгебраический тип (см. 9), обладающий конструкторами K1Kn.

Определим алгебраический тип F tau с конструкторами F1Fn, где сигнатура Fi получается из сигнатуры Kj заменой аргументов типа T на аргумент типа tau. Будем называть F схемой рекурсии для T.

Рассмотрим пример — дерево следующего вида:

data IntTree = Leaf | Fork Int IntTree IntTree

Для такого типа n=2, K1=Leaf, K2=Fork.

Схема рекурсии же для него такова:

data IntTree' tau = Leaf' | Fork' Int tau tau

Особенность такого типа в том, что он, будучи примененным к tau=IntTree, дает тип, эквивалентный IntTree; а при вычислении какого-либо значения снизу вверх этот тип соответствует типу «контекста» такого вычисления (известны данные текущего узла и результаты рекурсивных вызовов). Значение такого типа и является аргументом для процедуры свертки.

Итак, свёртка (вычисление снизу вверх) над типом T определяется функцией из соответствующего ему типа F tau в значение типа tau. Чтобы задать вычисление снизу вверх над типом деревьев, нужно задать функцию из IntTreetau в tau. Например, функция для подсчета количества листьев в дереве будет выглядеть так:

leafCountFold :: IntTree' Int -> Int leafCountFold Leaf' _ = 1 leafCountFold (Fork' _ p q) = p + q

Еще раз обратим внимание, что тип IntTree' сам по себе не рекурсивен, а аргументами Fork' вместо значений типа IntTreetau являются просто значения типа tau, т. е. результаты вычисления снизу вверх в непосредственных подструктурах.

Опишем функцию, выполняюшую свертку (вычисление снизу вверх) согласно заданной процедуре:

foldTree :: (IntTree' tau -> tau) -> IntTree -> tau foldTree f Leaf = f Leaf' foldTree f (Fork a t1 t2) = f (Fork' a (foldTree f t1) (foldTree f t2))

Тогда функция, выполняющая подсчет количества листьев в дереве, будет задаваться как countLeaves = foldTree leafCountFold.

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

copyTree = foldTree copyFold where copyFold Leaf' = Leaf copyFold (Fork' a t1 t2) = Fork a t1 t2

Полностью аналогично определяется свёртка для параметрических алгебраических типов, например:

data Tree α = Leaf | Fork α (Tree α) (Tree α) data Tree' α tau = Leaf' | Fork' α tau tau foldTree :: (Tree' α tau -> tau) -> Tree α -> tau foldTree f Leaf = f Leaf' foldTree f (Fork a t1 t2) = f (Fork' a (foldTree f t1) (foldTree f t2))

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


Рис. 7: Свёртка как подмена конструкторов

Теперь рассмотрим, во что вырождается понятие свертки в применении к спискам, задаваемым как data List α = Nil | Cons α (List α): схема рекурсии этого типа — тип data Listα tau = Nil' | Consα tau, поэтому тип сверточной процедуры — Listα tau -> tau; можно вместо процедуры такого типа задать две «процедуры», обрабатывающие каждый из двух возможных конструкторов, по отдельности: значение типа tau для конструктора Nil' и α -> tau -> tau для конструктора Cons'. Получающаяся процедура называется правой сверткой:

foldr :: (α -> tau -> tau) -> tau -> List α -> tau foldr withCons whenNil Nil = whenNil foldr withCons whenNil (Cons a xs) = withCons a (foldr withCons whenNil xs)

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

foldl :: (tau -> α -> tau) -> tau -> List α -> tau foldl update init Nil = init foldl update init (Cons x xs) = foldl update (update init x) xs

Формулы для левой и правой свертки выглядят так:

foldr (#) u [a1,a2,...,an] = a1 # (a2 # ... # (an # u)) foldl (#) u [a1,a2,...,an] = ((u # a1) # a2) # ... # an

В ленивом языке левая и правая свёртка принципиально различаются. Для правой свертки, как видно, результат, независимо от элементов a2 …, имеет вид a1 # …, и слабая заголовочная нормальная форма (СЗНФ, WHNF) этого выражения зачастую может быть вычислена и без вычисления свертки по всему остатку списка; это позволяет в т. ч. использовать правую свертку на бесконечных списках.

Различие для ленивого языка можно увидеть на примере реализации процедуры filter:

filter1 p xs = foldr (\x ys -> if p x then x:ys else ys) [] xs filter2 p xs = foldl (\ys x -> if p x then x:ys else ys) [] (reverse xs) > take 5 (filter1 even [1..]) -- Turns out 2:(4:(6:(...))) [2,4,6,8,10] > take 5 (filter2 even [1..]) -- Infinite loop, needs -- (reverse [1..]) first ^C

Проиллюстрируем ленивость foldr вычислением чуть более простого примера:

head (filter1 even [1..]) = head (foldr (\x ys -> if even x then x:ys else ys) [] [1..]) = head (if even 1 then 1:ys else ys where ys = foldr (\x ys -> if even x then x:ys else ys) [] [2..]) = head (foldr (\x ys -> if even x then x:ys else ys) [] [2..]) = head (if even 2 then 2:ys else ys where ys = foldr (\x ys -> if even x then x:ys else ys) [] [3..]) = head (2:foldr (\x ys -> if even x then x:ys else ys) [] [3..]) = 2

Каждое из равенств в цепочке соответствует всего лишь выполнению редукции — т. е. подстановке тела той или иной функции и фактических параметров ее вызова; не происходит ничего «волшебного» и никаких нетривиальных шагов упрощения: именно в таком порядке программа на Haskell и будет вычисляться.

У левой свертки есть более экзотический, но значительно более полезный аналог — строгая левая свёртка, отличающаяся лишь строгостью (энергичностью) (см. статью «Laziness» в Haskell wikibooks [14]) по «аккумулятору»:

foldl' :: (tau -> α -> tau) -> tau -> List α -> tau foldl' update !init Nil = init foldl' update !init (Cons x xs) = foldl (update init x) xs

В качестве примера алгоритма, требующего строгой левой свертки, можно привести вычисление суммы списка:

> foldr (+) 0 [0..1000000] Exception: stack overflow > take 2 (foldl (\ys x -> x:ys) [] [0..1000000]) [1000000,999999] > foldl (+) 0 [0..1000000] Exception: stack overflow > foldl' (+) 0 [0..1000000] 500000500000

В первом случае глубокая рекурсия внутри foldr приводит к переполнению стека.

Во втором случае результат foldl вычисляется итеративно (в смысле, указанном в статье о хвостовых вызовах (см. 7)) и успешно (в чем можно убедиться на третьем примере), однако результатом оказывается невычисленный терм (((0+1)+2)+...)+1000000, и переполнение стека происходит уже при попытке форсировать его вычисление для распечатки.

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

Итак:

Подробности, касающиеся различий левых и правых списочных сверток в отношении ленивости и строгости, описаны в статье «Foldr, Foldl, Foldl’» в Haskellwiki ([8]).


Рис. 8: Дерево вычисления ассоциативной свертки по сложению

В случае, когда тип результата свертки совпадает с типом элементов сворачиваемого списка, можно говорить о коммутативности (f x y == f y x) и ассоциативности ( f (f x yz == f x (f y z)) сверточной операции. Если операция ассоциативна, то такая операция называется списочным гомоморфизмом, левая и правая свертки по ней совпадают, и свертку можно вычислить с помощью «дерева» (на рис. 8 изображено дерево, образующееся в процессе вычисления суммы списка), что позволяет распараллелить вычисление (вычисляя дерево «по слоям» снизу вверх и распараллеливая вычисление каждого слоя) или сделать его инкрементальным (при изменении элемента списка в дереве затрагивается лишь O(log n) узлов, лежащих на пути от корня к нему; возможны также вставка/удаление/конкатенация за логарифмическое время). Многие параллельные алгоритмы основаны на таком подходе; узнать о нем больше можно в следующих источниках:

Если операция к тому же коммутативна, то результат свертки не зависит от порядка элементов в списке и можно еще увеличить степень параллелизма: фактически, достаточно в произвольном порядке объединять элементы и промежуточные результаты объединения по сверточной операции, пока не останется всего одно значение, которое и будет окончательным результатом. Все агрегатные функции SQL (MIN,MAX,SUM, DISTINCT,...) являются коммутативными свёртками.

Существует примечательная и очень полезная теорема — «третья теорема о гомоморфизмах» (описана в статье «The third homomorphism theorem» Джереми Гиббонса [70]). Она гласит, что если алгоритм можно реализовать как в виде левой свертки, так и в виде правой свертки с тем же начальным значением, то его можно реализовать и в виде ассоциативной свертки (которая поддается распараллеливанию и инкрементализации).



Использование
Чаще всего в функциональном программировании применяются списочные свертки и их аналоги. Приведем пару примеров:

В модуле Data.ByteString.Lazy байтовые потоки представляются в виде ленивого списка из байтовых массивов. Там же определена функция foldlChunks (код этого и следующего примера слегка модифицирован для лучшей понимаемости):

data LazyByteString = Empty | Chunk ByteString LazyByteString -- | Consume the chunks of a lazy ByteString with a -- strict, tail-recursive, accumulating left fold. foldlChunks :: (α -> ByteString -> α) -> α -> LazyByteString -> α foldlChunks f z = go z where go a _ | a `seq` False = undefined go a Empty = a go a (Chunk c cs) = go (f a c) cs

Эта функция применяется, например, в библиотеке digest [100] (привязка к процедурам вычисления CRC32 и Adler32 из zlib):

crc32_l_update :: Word32 -> LazyByteString -> Word32 crc32_l_update n = foldlChunks updateCRC n where updateCRC crc bs = crc32_c crc (ptr `plusPtr` offset) len where (ptr, offset, len) = toForeignPtr bs

То есть, при вычислении CRC32 от ленивого байтового потока функция crc32_c (обновление CRC32 заданным байтовым массивов) вызывается процедурой foldlChunks для каждого байтового массива, входящего в поток.

В сервере HAppS-Server [110] определена процедура для кодирования URL-адресов:

urlEncodeVars :: [(String,String)] -> String urlEncodeVars ((n,v):t) = let (same,diff) = partition ((==n) . fst) t in urlEncode n ++ '=' : folding ++ urlEncodeRest diff where folding = foldl (\x y -> x ++ ',' : urlEncode y) (urlEncode v) (map snd same)

Эта процедура группирует переданные параметры при помощи partition и для каждой последовательности параметров с одинаковым ключом при помощи foldl порождает отрезок строки вида foo=bar,baz,qux.

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

Одинаковый принцип действия сверток над любой структурой данных позволяет им обладать особыми алгебраическими свойствами, полезными для оптимизации программ: см. статью «Functional programming with bananas, lenses, envelopes and barbed wire» [117].

В языке Haskell концепция структур, поддающихся списочной свертке (даже если сама структура — не список, а, например, упорядоченное множество, реализованное с помощью дерева), выражена в классе типов (см. 13) Foldable, а более общая концепция, связанная со схемами рекурсии — в библиотеке «fixpoint» [109], реализующей (буквально в несколько строк) идеи из статьи «Functional programming with bananas …» [117], и аналогичной, но более современной и значительно более сложной библиотеке «multirec» [148].

В качестве примера применения сверток в нефункциональном языке можно привести API компилятора javac для работы с синтакическим деревом кода [25]. Класс TreeScanner представляет собой сверточную операцию почти в чистом виде: в нем есть функции для отображения листовых узлов и функция для комбинирования промежуточных результатов.

Одно из важнейших применений ассоциативных и коммутативных сверток — параллельное программирование. В статье «Prefix sums and their applications» [43] и книге «Vector models for data-parallel computing» [42] обсуждаются применения сверток и родственной им концепции «пробега» (англ. scan) к параллельному программированию. В настоящее время описанные в этих источниках алгоритмы применяются посвеместно, к примеру, при программировании графических процессоров.



Реализация
Почти все функциональные и динамические языки (Haskell, OCaml, Scala, Ruby, Python, Scheme, Perl и прочие) содержат в стандартной библиотеке аналог операции свертки над списками. Он есть даже в PHP, однако по неизвестным автору причинам поддерживаются только свертки над целыми числами (изучение исходного кода не показало каких-либо препятствий для снятия этого ограничения).

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

«Если свойство P выполняется для пустого списка, для всякого одноэлементного списка, и из xy и сортированности списка y::rest, удовлетворяющего P, можно вывести P для x::y::rest, то свойство P выполняется для любого сортированного списка».

Ознакомиться с Coq и с идеологией программирования с зависимыми типами можно, например, в книге «Coq’Art: Interactive theorem proving and program development» ([40]) и в презентации ([181]).

12  Структурная / вполне обоснованная рекурсия (индукция)
(Structural / well-founded recursion (induction))



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



Интуиция
Рассмотрим функцию поиска элемента в бинарном дереве поиска (binary search tree, BST)

find x Leaf = False find x (Fork a l r) | x == a = True | x < a = find x l | x > a = find x r

Почему мы можем быть уверены, что на всяком конечном дереве вызов этой функции завершается? Потому, что рекурсивный вызов find производится от левой или правой ветви, но нет такого конечного дерева, многократное взятие левой или правой ветви от которого никогда не приводит к листу. Поэтому рано или поздно дерево «закончится», а вместе с ним закончится и дерево рекурсивных вызовов.

Формально объяснить этот факт можно, например, следующими двумя способами:

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



Описание
Будем говорить, что отношение ≺ на множестве M является вполне обоснованным (понятие описано также в статье в Wikipedia «Well-founded relation» [22]), если не существует бесконечной последовательности a1a2 ≺ …. Можно переформулировать это свойство еще двумя способами: «в графе отношения ≺ нет циклов», или «в любом подмножестве M есть хотя бы один минимальный элемент относительно ≺».

Для конечных рекурсивно определенных структур данных (таких как списки, деревья и т. п.) отношение «ab, если a — подструктура b», очевидно, является вполне обоснованным.

Процедура f a b .. x .. называется структурно рекурсивной (см. также статью в Wikipedia «Structural induction» [19]) по аргументу x, где x имеет алгебраический тип (см. 9) T, если она определена для всех нерекурсивных конструкторов T (т. е. для минимальных элементов этого типа по отношению непосредственного включения) и ее определение для таких конструкторов не содержит рекурсивных вызовов, и при этом в уравнении для каждого из рекурсивных конструкторов T всякий рекурсивный вызов производятся от непосредственных аргументов этого конструктора. Если убрать из этого определения требование «процедура определена для всех конструкторов» и оставить лишь ограничение на аргументы рекурсивных вызовов, то полученное свойство будет гарантировать лишь завершаемость, но необязательно тотальность (т. е. наличие результата на всех возможных аргументах)15.

Например, вышерассмотренная процедура поиска значения в дереве структурно рекурсивна по дереву, чей тип обладает одним нерекурсивным конструктором Leaf и одним рекурсивным конструктором Fork. Вот этот тип:

data Tree α = Leaf | Fork α (Tree α) (Tree α)

Процедура была определена двумя уравнениями, первое из которых соответствует случаю Leaf и не содержит рекурсивных вызовов, а второе соответствует случаю, когда аргумент имеет форму Fork a l r, и рекурсивные вызовы производятся лишь от аргументов этого конструктора — l или r.

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

Завершимость, конечно, присутствует и в случае, когда вместо отношения «являться подструктурой» используется другое вполне обоснованное отношение между аргументами исходного и рекурсивного вызова. В этом случае говорят, что процедура определена при помощи вполне обоснованной индукции (см. также презентацию Ива Берто о вполне обоснованной индукции в Coq «Well-founded induction» [39]). В статье Лоуренса Полсона «Constructing recursion operators in intuitionistic type theory» [132] описан примененный в системе Coq способ конструктивной реализации вполне обоснованной индукции в системе типов, допускающей только структурную рекурсию.

Заметим, что натуральные числа также образуют алгебраический тип с двумя конструкторами: «ноль» и «n+1»: data Nat = Zero | Next Nat, поэтому можно считать структурно рекурсивными арифметические процедуры, определенные для n через результат для n−1.

Структурная рекурсия абстрагируется оператором свертки (см. 11).



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

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

Рассмотрим пару примеров функций, определенных при помощи структурной рекурсии, или, напротив, не являющихся структурно рекурсивными, а затем — функций, определенных при помощи вполне обоснованной индукции или не являющихся таковыми.

Функция вычисления длины списка структурно рекурсивна по списку:

length [] = 0 length (x:xs) = 1 + length xs

Рекурсивный вызов производится от терма xs, являющегося непосредственным аргументом конструктора (:) в терме (x:xs).

Функция вычисления факториала структурно рекурсивна по своему аргументу-натуральному числу.

factorial 0 = 1 factorial n = n * factorial (n-1)

Функция вставки в бинарное дерево (для простоты рассмотрим несбалансированное дерево) структурно рекурсивна по аргументу-дереву:

insert x Leaf = Fork x Leaf Leaf insert x (Fork a l r) | x == a = Fork a l r | x < a = Fork a (insert x l) r | x > a = Fork a l (insert x r)

Рекурсивные вызовы производятся от термов l или r, являющихся непосредственными аргументами конструктора Fork в терме (Fork a l r).

Идиоматическая реализация быстрой сортировки списка17 не является структурно рекурсивной:

qsort [] = [] qsort (x:xs) = qsort (filter (<=x) xs) ++ [x] ++ qsort (filter (>x) xs)

Как видно, здесь аргументы рекурсивных вызовов — термы вида filter (>xxs, не являющиеся непосредственными аргументами конструктора (:) в терме (x:xs).

Впрочем, эта реализация является, например, вполне обоснованно рекурсивной, поскольку рекурсивные вызовы производятся от списков, чья длина строго меньше длины исходного списка (это рассуждение основано на таком свойстве filter, как length (filter p xs) <= length xs).

Процедура объединения двух сортированных списков является вполне обоснованно рекурсивной:

merge [] xs = xs merge xs [] = xs merge (x:xs) (y:ys) | x <= y = x : merge xs (y:ys) -- (A) | x > y = y : merge (x:xs) ys -- (B)

В данном случае имеет место вполне обоснованная индукция: аргументы рекурсивных вызовов merge (обозначим их xs', ys') связаны с аргументом исходного (обозначим их xsys) следующим вполне обоснованным отношением:

length xs' + length ys' < length xs + length ys

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

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

merge xs ys = merge' xs ys (length xs + length ys) merge' _ _ 0 = [] -- (X) merge' [] (x:xs) n = x : merge' [] xs (n-1) merge' (x:xs) [] n = x : merge' xs [] (n-1) merge' (x:xs) (y:ys) n | x <= y = x : merge xs (y:ys) (n-1) | x > y = y : merge (x:xs) ys (n-1)

Обратим внимание, как было изменено определение merge, чтобы удовлетворить свойству структурной рекурсивности: были в явной форме выделены уравнения для каждого из конструкторов, составляющих алгебраический тип [Int] третьего аргумента.

Теперь явно имеет место структурная рекурсия по третьему аргументу (заметим, что без уравнения (X) функция не была бы структурно рекурсивной, т. к. не был бы рассмотрен базовый (минимальный) случай относительно аргумента структурной рекурсии; была бы гарантирована лишь завершимость, но не тотальность).

Рассмотрим еще один пример: вычисление наибольшего общего делителя.

gcd 0 x = x gcd x 0 = x gcd a b | a <= b = gcd a (b-a) | a > b = gcd (a-b) b

Эта процедура также не является структурно рекурсивной, однако является вполне обоснованно рекурсивной по отношению между аргументами рекурсивного (a', b') и исходного (ab) вызовов «a’+b’ < a+b», поэтому она гарантированно завершается. Вполне обоснованная индукция становится несколько более явной, если ввести дополнительный аргумент, как и в случае mergesort:

gcd x y = gcd' x y (x+y) gcd' 0 x _ = x gcd' x 0 _ = x gcd' a b s | a <= b = gcd a (b-a) b | a > b = gcd (a-b) b a

Теперь стало ясно видно, что для любого вызова gcda b s верно a+b == s, следовательно при a/=0 && b/=0 верно a<s && b<s, следовательно при рекурсивных вызовах аргумент s уменьшается, но становится нулем только если a==0 && b==0, а в этом случае «сработает» какое-либо из первых двух уравнений. Поэтому функция gcd является и завершающейся, и тотальной.

Примеры mergesort и gcd взяты из презентации Ива Берто «Well-founded induction» [39].

Еще одно часто применяемое в специфических областях вполне обоснованное отношение — т. н. «гомеоморфное вложение». Это — отношение над формулами, и его свойство вполне обоснованности применяется в таких областях, как системы переписывания термов (например, при упрощении выражений), суперкомпиляция и т. п. Это отношение и его приложение к суперкомпиляции описаны, например, в статье Мортена Соренсена и Роберта Глюка «An algorithm of generalization in positive supercompilation» ([157]).



Реализация
Языки Coq, Epigram, Agda допускают только структурную рекурсию, причем в Coq при введении структурно рекурсивного определения необходимо явно указывать аргумент, по которому производится структурная рекурсия; как следствие, все процедуры на этих языках завершаются (если исключить из рассмотрения присутствующую в Coq коиндукцию). В стандартной библиотеке языка Coq есть модуль «Coq.Init.Wf» ([29]), реализующий вполне обоснованную индукцию. В последних версиях реализована также экспериментальная команда Function, позволяющая непосредственно определять не-структурно рекурсивные функции и отдельно доказывать свойство вполне обоснованности отношения между аргументами исходного и рекурсивных вызовов. Кроме того, Coq при определении индуктивного типа данных (см. 9) генерирует определения, соответствующие операторам структурной рекурсии над этим типом.

13  Класс типов
(Type class)



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



История
Классы типов были предложены Филипом Вадлером в 1989 году в статье «How to make ad-hoc polymorphism less ad hoc» ([174]) в качестве варианта специального полиморфизма. Изначально предполагалось применить их для реализации перегрузки арифметических операторов и операторов сравнения. Авторы работы были недовольны сложившейся ситуацией: на тот момент среди разработчиков языков программирования отсутствовало общее мнение о том, как следует решать связанные с этим проблемы. К примеру, язык ML вводил специальное понятие «типов, сравнимых на равенство» (eqtypes), а стандарт этого языка хоть и упоминал возможность перегрузки арифметических операторов, но не уточнял, что это такое.

Классы типов оказались удачным решением и вошли в стандарт языка Haskell. С тех пор было найдено огромное множество различных применений классов типов, они стали одним из основных элементов системы типов Haskell, его «визитной карточкой», а также стали проникать в некоторые другие языки (Mercury, Coq). Кроме того, был предложен ряд расширений, увеличивающих выразительную силу и удобство пользования классами типов; вот некоторые из них:



Интуиция
Основная цель интерфейсов в ООП — абстрагирование конкретного типа некоторого значения от клиента, производящего над значением лишь ограниченный и заранее известный набор операций (в данной статье здесь и далее «клиент» — код, использующий данные операции).

Интерфейсы позволяют:

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

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

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

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



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

class Hashable tau where hash :: tau -> Int equal :: tau -> tau -> Bool

В этом примере объявлен класс Hashable: «тип, годный в качестве ключа хэш-таблицы». Класс состоит из двух сигнатур операций: операции хэширования и операции сравнения на равенство (обратите внимание на отличие сигнатуры equal от сигнатуры equals в таких ОО-языках, как Java или C#).

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

makeHashtable :: (Hashable key) => [(key, val)] -> (key -> Maybe val)

Такая форма полиморфизма называется «ограниченным параметрическим полиморфизмом».

В этом примере функция makeHashtable, требует, чтобы тип key принадлежал классу типов Hashable. Она принимает на вход список пар (key,val), а возвращает функцию поиска, отображающую ключ типа key в значение типа val или в сигнал об отсутствии значения для этого ключа.

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

instance Hashable (Int,Int) where hash (x,y) = x `xor` y equal (a,b) (c,d) = (a==c) && (b==d)

Очень часто можно использовать при реализации класса типов реализацию других классов типов. Например, при реализации Hashable для пар типа (α,β) можно использовать реализацию Hashable для α и для β:

instance Hashable α, Hashable β => Hashable (α,β) where hash (a,b) = (33*hash a) + hash b equal (a1,b1) (a2,b2) = equal a1 a2 && equal b1 b2

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

class Convertible α β where convert :: α -> Maybe β instance Convertible Double Int32 where convert x | x < -2147483648.0 = Nothing | x > 2147483647.0 = Nothing | otherwise = Just (round x)

Понятие класса типов также применяется к параметрическим типам с одним или несколькими аргументами, например, Tree α; такие типы также называются «типовыми конструкторами»18. В этом случае сигнатуры операций класса могут включать в себя применение этого конструктора. В нижеприведенном примере записан класс типов, соответствующий понятию коллекции с операциями «размер», «преобразование в список и обратно», «проверка вхождения», «добавление и удаление», и определена его реализация для конструктора типа списков [α].

class Collection kappa α where size :: kappa α -> Int fromList :: [α] -> kappa α toList :: kappa α -> [α] elem :: α -> kappa α -> Bool add :: α -> kappa α -> kappa α remove :: α -> kappa α -> kappa α instance (Eq α) => Collection [α] α where size = length fromList = id toList = id elem = Data.List.elem add x xs | elem x xs = xs | otherwise = x:xs remove x xs = filter (/= x) xs



Использование
Выше уже был приведен один пример использования классов типов: библиотека хэш-таблиц. Приведем еще несколько примеров их использований из библиотек языка Haskell.



Реализация
Наиболее распространенный язык, поддерживающий классы типов — это Haskell. Именно в нем они впервые появились в 1989 г. В 1997 г. в компиляторе GHC появилась поддержка многопараметрических классов типов, однако в стандарт Haskell98 не вошла. В 2005 г. появилась поддержка семейств типов.

На странице «Research papers/Type systems» в разделе «Type classes» [17] в Haskellwiki можно найти множество исследовательских работ, посвященных различным аспектам семантики, реализации и применения классов типов.

Также классы типов поддерживаются в функционально-логическом языке Mercury, в языке Coq версии 8.2, а также некоторых других языках.

В языке OCaml есть концепция, родственная классам типов — модули. В статьях Чакраварти и других «ML modules and Haskell type classes: a constructive comparison» [177] и «Modular type classes» [61] приведено их сравнение, показаны параллели между их возможностями и стилем программирования.

«Концепты», так и не вошедшие в стандарт C++0x, также схожи по своим возможностям с классами типов. В статье «A comparison of C++ concepts and Haskell type classes» Жана-Филиппа Бернарди и других [38] приведено их сравнение. Эта статья чрезвычайно интересна и ее стоит прочитать даже тем, кто не интересуется C++, поскольку в ней само понятие класса типов «разбирается по косточкам», рассматриваются его составляющие, оси изменчивости возможностей и обсуждается роль этих возможностей в программировании.



Имитация
Нередко для одного и того же типа возможно несколько различных реализаций (экземпляров) класса. Например, для записи с тремя полями существует, как минимум, 27 различных реализаций класса Ord, производящих лексикографическое сравнение разных подмножеств полей в различном порядке, по возрастанию или убыванию; и это не считая даже того, что и сравнение самих полей может производиться по-разному. Не для всех типов данных имеется какая-то одна «наиболее естесственная» реализация некоторого класса.

Один из способов решить подобную проблему таков: создается новый тип-обертка вокруг исходного типа, и экземпляр объявляется для типа-обертки. Например, вот так можно «перевернуть» порядок над некоторым типом:

newtype Desc α = Desc α instance (Eq α) => Eq (Desc α) where (Desc x) == (Desc y) = x == y instance (Ord α) => Ord (Desc α) where (Desc x) <= (Desc y) = y <= x > sort [Desc 3, Desc 8, Desc 5] [Desc 8, Desc 5, Desc 3]

Еще один яркий пример такого подхода — модуль Data.Monoid [27]. Однако данный прием все же предполагает, что все возможные реализации фиксированы и известны статически, и не позволяет создать реализацию класса для некоторого типа динамически: скажем, полиморфная процедура сортировки, сортирующая значения типа α, где Ord α, сгодится для сравнения элементов списка по задаваемому пользователем условию (скажем, для сортировки рядов таблицы по заданным столбцам). Поэтому процедурам сортировки приходится принимать на вход процедуру сравнения непосредственно.

Непосредственная передача функциям реализаций всех операций для типа называется стилем передачи словарей (dictionary-passing style). Например, сравним сигнатуры процедур для построения хэш-индекса с использованием классов типов и с использованием стиля передачи словарей:

makeHashtableTC :: (Hashable key) => [(key, val)] -> (key -> Maybe val) makeHashtableDP :: (key -> Int, key -> key -> Bool) -> [(key, val)] -> (key -> Maybe val)

В объектно-ориентированных языках часто используется еще один прием имитации классов типов: составление словаря «тип → реализации операций для него» в явной форме. Скажем, библиотека сериализации может быть устроена примерно так:

interface SerialForm<T> { byte[] serialize(T t); T deserialize(byte[] data); } class Serializer { public <T> void useSerialForm( Class<T> clazz, SerialForm<T> serialForm); byte[] serialize(Object data); } ... s.useSerialForm(Integer.class, new IntBigEndianSF()); s.useSerialForm(String.class, new StringSF()); ...

Еще один любопытный пример использования стиля передачи словарей приведен в статье о замыканиях (см. 4), в секции «Использование».

14  Комбинáторная библиотека
(Combinator library)



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



Интуиция
Рассмотрим задачу проверки пользовательского ввода: например, является ли заданная строка записью числа с плавающей точкой или, скажем, даты в формате YYYY/Mon/DD HH:MM:SS. Есть как минимум три различных подхода к решению такой задачи:

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

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

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

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

Оба способа моделируют предметную область «Проверка строк на принадлежность к некоторому языку».

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

Второй способ моделирует предметную область непосредственно, хотя и более узко. Язык регулярных выражений специально заточен под комбинируемость, и из двух регулярных выражений, моделирующих языки A и B, легко собрать третье, моделирующее язык AB={a b|aAbB} и т. п. Предметная область собирается, например, из следующих элементов и способов их комбинирования:

Примитивы:

и способы их комбинирования:

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

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

Регулярные выражения — пример очень простого и понятного, но в то же время невероятно мощного и универсального приема моделирования (не только в программировании) — комбинáторной модели предметной области.



Описание
Программные библиотеки, моделирующие предметную области при помощи комбинáторной модели, называются комбинáторными библиотеками.

Для комбинаторных библиотек характерно:

Соответствие терминологии библиотеки и терминологии предметной области.

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

Состав: типы, примитивы, комбинаторы первого порядка, комбинаторы высшего порядка.

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

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

Комбинаторы могут выражаться друг через друга: например, в случае регулярных выражений верно A? = A|() и A+ = AA∗.

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

Как и у любой другой программы, терминологическая основа комбинаторной библиотеки — это типы используемых в ней сущностей. Типы определяют, в частности, в каком контексте уместны какие примитивы и комбинаторы, и какие из них совместимы друг с другом. Так, логично, что в случае комбинаторной библиотеки для описания потоков данных (например, электронных схем) комбинатор «последовательная композиция блока с самим собой N раз» уместен только если типы входа и выхода блока совпадают.

Одно из современных «веяний» в типизации в комбинаторных библиотеках — использование GADT (обобщенных алгебраических типов) (см. 9). Например, см. иллюстрацию этой техники для парсеров в соответствующем разделе статьи о GADT в haskellwiki ([9]) и модель представления патчей в системе контроля версий darcs, описанную в презентации Ганеша Ситтампалама «Darcs and GADTs» ([154]).

Свойство замыкания.

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

Нарушение свойства замыкания делает язык не только сложнее (так как становится необходимо различать простые и составные сущности и помнить, в каком контексте какие из них допустимы), но и менее выразительным из-за невозможности использовать некоторые комбинаторы в некоторых контекстах. Более того, это закрывает путь к развитию концепции, навсегда ограничивая ее рамками первоначального замысла создателя. Автор данной статьи принадлежит к сторонникам мысли «Путь к выразительности языка — не добавление возможностей, а устранение искусственных ограничений» (также известна цитата: «Expressive power should be by design rather than by accident» из классической статьи Питера Ландина «The next 700 programming languages» [105]).

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

Возможность эффективной реализации.

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

Почти всегда имеет место компромисс между эффективностью реализации и универсальностью20. Например, регулярные выражения в самом простом случае можно чрезвычайно эффективно реализовать при помощи компиляции в конечный автомат (преобразование подробно описано в статье «Regular expression matching can be simple and fast» Расса Кокса [57]); в более сложных языках регулярных выражений также обычно стараются предоставить набор операторов, допускающий эффективную реализацию.

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

Однако этого удается достичь не всегда: например, в случае нелинейных преобразований точек ничего не остается, кроме как применять композицию из нескольких преобразований путем последовательного применения каждого из них, т. е. композиция тысячи преобразований будет, по меньшей мере, в тысячу раз менее эффективна, чем каждое преобразование по отдельности. Более того, свои издержки вносит и сама «инфраструктура» библиотеки; стоит проектировать библиотеку так, чтобы эти издержки были минимальны, т. е. чтобы библиотека переносила «тяжелые» операции в примитивы. Этот подход рассмотрен в упомянутой лекции 4 курса [186] по отношению к преобразованиям координат.

Джон Хьюз читал целый курс о комбинаторных библиотеках «Designing and Using Combinators: The Essence of Functional Programming»; материалы курса свободно доступны в интернете ([83]).



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

В знаменитой статье «An experiment in software prototyping productivity» [82] Пола Хьюдэка и Марка Джонса на стр. 9 описывается, как программист на Haskell разработал для решения задачи комбинаторную библиотеку (язык представления геометрических областей), благодаря чему его код оказался в 2 раза короче, чем код ближайшего конкурента (и в 13 раз короче, чем код на С++)21.

Аналогичный язык для представления картинок и анимаций кратко рассмотрен в очень интересной статье Пола Хьюдэка «Modular domain specific languages and tools» [81], где также рассмотрены и некоторые другие аспекты разработки комбинаторных библиотек.

Существует много библиотек для комбинаторного представления графики: [130], [3] (модуль Grid) и т. п.

Одно из самых удачных применений комбинаторных библиотек (в том числе и за пределами ФП) — это библиотеки для синтаксического анализа. Одна из лучших статей на данную тему — статья Филипа Вадлера «How to replace failure by a list of successes» [171], а также статья Грэхема Хаттона «Higher-order functions for parsing» [87] и его совместная статья с Эриком Мейером «Monadic parsing in Haskell» [89]. Данный подход оказался настолько хорош, что портированные комбинаторные библиотеки синтаксического разбора существуют для очень большого количества языков, далеко не только функциональных. Однако, пользуются ими все же почти исключительно в функциональных языках — вероятно, из-за неудобства манипулирования комбинаторами без синтаксической поддержки монад, ФВП (см. 3) и замыканий (см. 4). В презентации Эдварда Кметта «Iteratees, Parsec and monoids» [103] описан подход, позволяющий сделать комбинаторы синтаксического разбора инкрементальными, однако уровень сложности этого материала очень высокий, требуется знание некоторых других концепций, в частности, монад и т. н. iteratees.

В статье Криса Окасаки «Why would anyone ever want to use a sixth-order function?» [126] описаны, в приложении к синтаксическому разбору, примеры полезных комбинаторов чрезвычайно высоких порядков, вплоть до 6го.

В книге «Структура и интерпретация компьютерных программ» [32] в главе 2.2.4 рассмотрен игрушечный «язык картинок», выстроенный в форме комбинаторной библиотеки с комбинаторами высшего порядка.

В статье «The design of a pretty-printing library» [85] (автор которой, опять же, Джон Хьюз) рассмотрена комбинаторная библиотека для представления и «красивого» форматирования текстов. Это применение стало одной из классических иллюстраций идеи комбинаторной библиотеки, а за этой статьей позднее последовало множество других на ту же тему («A prettier printer» Филипа Вадлера [173], «Pretty printing with lazy deques» Олафа Читила [50], «A pretty printer library in Haskell» Саймона Пейтона-Джонса [135], «Beyond pretty-printing: Galley concepts in document formatting combinators» Вольфрама Каля [96], «Linear, bounded, functional pretty-printing» Доатсе Свирстры [162], «Optimal pretty-printing combinators» [34] Азеро и Свирстры, и т. п.).

Знаменитая библиотека случайного тестирования свойств QuickCheck Коэна Клэссена ([51], описана в статье «Specification based testing with QuickCheck» [52]) также представляет собой пример комбинаторной библиотеки.

Язык образцов (см. 10) в Mathematica сходен с регулярными выражениями и также основан на примитивах и комбинаторах.

В статьях «Imperative streams — a monadic combinator library for synchronous programming» [150] и «A monad of imperative streams» [149] Энно Шольца рассматривается комбинаторная библиотека для программирования, основанного на событиях (функционального реактивного программирования), ставшая базисом библиотеки для построения пользовательского интерфейса FranTk. Библиотека FranTk так и не стала широко применяться, однако идеи функционального реактивного программирования дали начало, по меньшей мере, двум гораздо более удобным, мощным и распространенным средствам:

В презентации «Функциональное программирование в Java» Евгения Кирпичёва [184] описывается комбинаторная библиотека для асинхронного программирования на Java, также использующая монаду продолжений. F# реализует похожую библиотеку («asynchronous workflows», описаны в блог-посте основного разработчика F# Дона Сайма «Introducing F# asynchronous workflows»[163], в его же видео «What’s new in F# — asynchronous workflows» [164], в статье Роберта Пикеринга «Beyond foundations of F# — asynchronous workflows» [138] и т. п.) при помощи использования монад в явной форме (workflow — термин из F#, фактически совпадающий с понятием монады).

Еще одна известная и получившая в свое время довольно широкую известность статья «Composing contracts: an adventure in financial engineering (functional pearl)» [94] описывает комбинаторную библиотеку для задания финансовых контрактов.

В статье «Функциональное программирование в Java» [183] приведено несколько примеров использования гипотетических и реальных комбинаторных библиотек на Java.

Список литературы

[1]
Arrays. Страница в haskellwiki, http://www.haskell.org/haskellwiki/Arrays.
[2]
Authorization structures. Страница MSDN, http://msdn.microsoft.com/en-us/library/aa375780(VS.85).aspx.
[3]
Chart: A library for generating 2d charts and plots. http://hackage.haskell.org/package/Chart.
[4]
Church encoding. Статья в Wikipedia, http://en.wikipedia.org/wiki/Church_encoding.
[5]
Continuations and continuation passing style. http://library.readscheme.org/page6.html.
[6]
Copy-on-write. Статья в Wikipedia, http://en.wikipedia.org/wiki/Copy-on-write.
[7]
The coq proof assistant. http://coq.inria.fr/.
[8]
Foldr, foldl, foldl’. Страница в haskellwiki, http://www.haskell.org/haskellwiki/Foldr_Foldl_Foldl'.
[9]
Generalized algebraic datatype: Parsing example. Статья в HaskellWiki, http://www.haskell.org/haskellwiki/GADT\#Parsing_Example.
[10]
heap: Heaps in haskell. http://hackage.haskell.org/package/heap.
[11]
Hipmunk: A haskell binding for chipmunk. http://hackage.haskell.org/package/Hipmunk.
[12]
J software. http://jsoftware.com.
[13]
Kx systems - fast database for streaming and historical data. http://kx.com.
[14]
Laziness. Страница в Haskell Wikibooks, http://en.wikibooks.org/wiki/Haskell/Laziness.
[15]
Perfecthash: A perfect hashing library for mapping bytestrings to values. http://hackage.haskell.org/package/PerfectHash.
[16]
Problem with delegates in c#. Вопрос на форуме Stack Overflow, http://stackoverflow.com/questions/1660483/problem-with-delegates-in-c.
[17]
Research papers/type systems/type classes. Страница в haskellwiki, http://haskell.org/haskellwiki/Research_papers/Type_systems\#Type_classes.
[18]
Revised(6) report on the algorithmic language scheme. http://www.r6rs.org/.
[19]
Structural induction. Статья в Wikipedia, http://en.wikipedia.org/wiki/Structural_induction.
[20]
hackageDB :: [Package]. http://hackage.haskell.org/packages/hackage.html.
[21]
Type inference: Hindley-milner type inference algorithm. Секция статьи в Wikipedia, http://en.wikipedia.org/wiki/Type_inference\#Hindley-Milner_type_inference_algorithm.
[22]
Well-founded relation. Статья в Wikipedia, http://en.wikipedia.org/wiki/Well-founded_relation.
[23]
Библиотека functional java. http://functionaljava.org.
[24]
Библиотека match.ss для plt scheme. http://download.plt-scheme.org/doc/372/html/mzlib/mzlib-Z-H-27.html.
[25]
Документация к классу treescanner. http://java.sun.com/javase/6/docs/jdk/api/javac/tree/com/sun/source/util/TreeScanner.html.
[26]
Документация к модулю data.map стандартной библиотеки языка haskell. http://www.haskell.org/ghc/docs/latest/html/libraries/containers/Data-Map.html.
[27]
Документация к модулю data.monoid. http://www.haskell.org/ghc/docs/latest/html/libraries/base/Data-Monoid.html.
[28]
Документация к пакету struct.ss из библиотеки языка plt scheme. http://planet.plt-scheme.org/package-source/dherman/struct.plt/2/4/doc.txt.
[29]
Модуль coq.init.wf, реализующий вполне обоснованную индукцию. http://coq.inria.fr/stdlib/Coq.Init.Wf.html.
[30]
Страница в haskellwiki «existential type». http://www.haskell.org/haskellwiki/Existential_type.
[31]
Страница в haskellwiki «oop vs type classes». http://www.haskell.org/haskellwiki/OOP_vs_type_classes.
[32]
Harold Abelson and Gerald J. Sussman. Structure and Interpretation of Computer Programs - 2nd Edition (MIT Electrical Engineering and Computer Science). The MIT Press, 2 edition, July 1996.
[33]
Emil Axelsson. Bookshelf: A simple document organizer with some wiki functionality. Пакет на hackage, http://hackage.haskell.org/package/Bookshelf.
[34]
P. Azero and S. D. Swierstra. Optimal pretty-printing combinators, April 1998.
[35]
J. Backus. Can programming be liberated from the von neumann style? Communications of the ACM, 21(8):613–641, 1978.
[36]
H. P. Barendregt. Lambda calculi with types. pages 117–309, 1992.
[37]
Andreas Bauer. Compilation of functional programming languages using GCC—Tail calls. Master’s thesis, Institut für Informatik, Technische Universität München, 2003.
[38]
Jean-Philippe Bernardy, Patrik Jansson, Marcin Zalewski, Sibylle SChupp, and Andreas P. Priesnitz. A comparison of c++ concepts and haskell type classes. In Ralf Hinze and Don Syme, editors, ICFP-WGP, pages 37–48. ACM, 2008.
[39]
Yves Bertot. Well-founded induction. Слайды, http://www-sop.inria.fr/members/Yves.Bertot/tsinghua/tsinghua-5.pdf.
[40]
Yves Bertot and Pierre Castéran. Interactive Theorem Proving and Program Development. Coq’Art: The Calculus of Inductive Constructions. Texts in Theoretical Computer Science. Springer Verlag, 2004.
[41]
Richard Bird and Lambert Meertens. Nested datatypes. In MPC: 4th International Conference on Mathematics of Program Construction. LNCS, Springer-Verlag, 1998.
[42]
G. Blelloch. Vector Models for Data-Parallel Computing. The MIT Press, Cambridge, MA, 1990.
[43]
Guy E. Blelloch. Prefix sums and their applications. February 1993.
[44]
Chandrasekhar Boyapati, Barbara Liskov, and Liuba Shrira. Ownership types for object encapsulation. In In Principles of Programming Languages (POPL), pages 213–223, 2003.
[45]
Peter Brass. Advanced Data Structures. Cambridge: Cambridge University Press, 2009.
[46]
Bjorn Bringert and Duncan Coutts. tar: Reading, writing and manipulating ".tar" archive files. Пакет на hackage, http://hackage.haskell.org/package/tar, 2007–2009.
[47]
F. Cardone and J. R. Hindley. History of lambda-calculus and combinatory logic. Handbook of the History of Logic, 5.
[48]
Manuel M. T. Chakravarty, Gabriele Keller, and Simon Peyton Jones. Associated type synonyms. ACM SIGPLAN Notices, 40(9):241–253, September 2005.
[49]
Manuel M. T. Chakravarty, Gabriele Keller, Simon Peyton Jones, and Simon Marlow. Associated types with class. ACM SIGPLAN Notices, 40(1):1–13, January 2005.
[50]
Olaf Chitil. Pretty printing with lazy dequeues. ACM Trans. Program. Lang. Syst., 27(1):163–184, January 2005.
[51]
Koen Claessen. Quickcheck: Automatic testing of haskell programs. Пакет на hackage, http://hackage.haskell.org/package/QuickCheck.
[52]
Koen Claessen and John Hughes. Specification based testing with QuickCheck. In The Fun of Programming, Cornerstones of Computing, pages 17–40. Palgrave, 2003.
[53]
Martin Clare E., Jeremy Gibbons, and Ian Bayley. Disciplined, efficient, generalised folds for nested datatypes. Formal Asp. Comput, 16(1):19–35, 2004.
[54]
John Clements and Matthias Felleisen. A tail-recursive machine with stack inspection. ACM Trans. Program. Lang. Syst., 26(6):1029–1052, 2004.
[55]
William D. Clinger. Proper tail recursion and space efficiency. In Proceedings of the ACM SIGPLAN ’98 Conference on Programming Language Design and Implementation, pages 174–185, Montréal, Québec, June 1998.
[56]
Duncan Coutts. zlib: Compression and decompression in the gzip and zlib formats. Пакет на hackage, http://hackage.haskell.org/package/zlib.
[57]
Russ Cox. Regular expression matching can be simple and fast. http://swtch.com/~rsc/regexp/regexp1.html, January 2007.
[58]
Olivier Danvy and Lasse R. Nielsen. Defunctionalization at work. In PPDP ’01: Proceedings of the 3rd ACM SIGPLAN international conference on Principles and practice of declarative programming, pages 162–174, New York, NY, USA, 2001. ACM.
[59]
Pedro Del Gallego. Curried function and labeled arguments in ocaml. Пост в блоге, http://theplana.wordpress.com/2007/04/26/curried-function-and-labeled-arguments-in-ocaml/, April 2007.
[60]
Scott E. Dillard. Vec: Fixed-length lists and low-dimensional linear algebra. Пакет на hackage, http://hackage.haskell.org/package/Vec, 2009.
[61]
Derek Dreyer, Robert Harper, Manuel M. T. Chakravarty, and Gabriele Keller. Modular type classes. In Martin Hofmann and Matthias Felleisen, editors, POPL, pages 63–70. ACM, 2007.
[62]
Driscoll, Sarnak, Sleator, and Tarjan. Making data structures persistent. JCSS: Journal of Computer and System Sciences, 38, 1989.
[63]
Matthias Felleisen. Functional objects. Слайды с выступления на ECOOP 2004, http://www.ccs.neu.edu/home/matthias/Presentations/ecoop2004.pdf.
[64]
Darrell Ferguson and Dwight Deugo. Call with current continuation patterns. In Proceedings of the 2001 Pattern Languages of Programs Conference, 2001.
[65]
Sigbjorn Finne. json: Support for serialising haskell to and from json. Пакет на hackage, http://hackage.haskell.org/package/json.
[66]
Matthew Flatt. Getting rid of set-car! and set-cdr! Пост в блоге PLT Scheme Blog, http://blog.plt-scheme.org/2007/11/getting-rid-of-set-car-and-set-cdr.html.
[67]
Neal Gafter. Comments on the straw man…. http://mail.openjdk.java.net/pipermail/lambda-dev/2009-December/000023.html.
[68]
Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley Professional, 1995.
[69]
Neil Ghani, Makoto Hamana, Tarmo Uustalu, and Varmo Vene. Representing cyclic structures as nested datatypes.
[70]
Jeremy Gibbons. The Third Homomorphism Theorem. Journal of Functional Programming, 6(4):657–665, July 1996. Functional Pearls.
[71]
Jean-Yves Girard, Yves Lafont, and Paul Taylor. Proofs and Types, volume 7 of Cambridge Tracts in Theoretical Computer Science. Cambridge University Press, 1989. This is textbook on proof theory and type systems, based on lectures by Girard. It contains an appendix by Lafont on linear logic, and also treats Girard’s polymorphic lambda calculus.
[72]
John Goerzen. Hdbc: Haskell database connectivity. Пакет на hackage, http://hackage.haskell.org/package/HDBC.
[73]
John Goerzen. Hsh: Library to mix shell scripting with haskell programs. Пакет на hackage, http://hackage.haskell.org/package/HSH.
[74]
Fritz Henglein. Generic discrimination: Sorting and paritioning unshared data in linear time. In James Hook and Peter Thiemann, editors, Proceeding of the 13th ACM SIGPLAN international conference on Functional programming, ICFP 2008, Victoria, BC, Canada, September 20-28, 2008, pages 91–102. ACM, 2008.
[75]
Rich Hickey. Trampoline for mutual recursion. Обсуждение в рассылке clojure, http://groups.google.com/group/clojure/browse_thread/thread/6257cbc4454bcb85/3addf875319c5c10.
[76]
Roger Hindley. Сообщение об истории алгоритма вывода типов Хиндли-Милнера. Сообщение в рассылке TYPES в MIT, http://www.cis.upenn.edu/~bcpierce/types/archives/1988/msg00042.html, 1988.
[77]
Ralf Hinze. Efficient generalized folds. In Johan Jeuring, editor, Proc. of 2nd Workshop on Generic Programming, WGP’2000 (Ponte de Lima, Portugal, 6 July 2000), Tech. Report UU-CS-2000-19, Dept. of Computer Science, Utrecht Univ. June 2000.
[78]
Ralf Hinze. Fun with phantom types. In Jeremy Gibbons and Oege de Moor, editors, The Fun of Programming, pages 245–262. Palgrave Macmillan, 2003. ISBN 1-4039-0772-2 hardback, ISBN 0-333-99285-7 paperback.
[79]
Ralf Hinze, Johan Jeuring, and Andres Löh. Typed Contracts for Functional Programming, volume 3945. January 2006.
[80]
Martin Holters. Efficient data structures in a lazy functional language, November 2003.
[81]
Paul Hudak. Modular domain specific languages and tools. In Proceedings of Fifth International Conference on Software Reuse, pages 134–142, June 1998.
[82]
Paul Hudak and Mark P. Jones. Haskell vs. ada vs. c++ vs awk vs ... an experiment in software prototyping productivity. Technical report, 1994.
[83]
John Hughes. Designing and using combinators: The essence of functional programming. http://www.math.chalmers.se/~rjmh/Combinators/.
[84]
John Hughes. Why functional programming matters. Technical Report 16, Programming Methodology Group, University of Goteborg, November 1984.
[85]
John Hughes. The design of a pretty-printing library. pages 53–96. 1995.
[86]
Jafar Husain. Introducing rx (linq to events). Пост в блоге, http://themechanicalbride.blogspot.com/2009/07/introducing-rx-linq-to-events.html.
[87]
Graham Hutton. Higher-order Functions for Parsing. Journal of Functional Programming, 2(3):323–343, July 1992.
[88]
Graham Hutton. A tutorial on the universality and expressiveness of fold. J. Funct. Program, 9(4):355–372, 1999.
[89]
Graham Hutton and Erik Meijer. Monadic parsing in haskell. Journal of Functional Programming, 8(4):437–444, July 1998.
[90]
James Iry. Tail calls via trampolining and an explicit instruction. Обсуждение в рассылке Scala, http://old.nabble.com/Tail-calls-via-trampolining-and-an-explicit-instruction-td20702915.html.
[91]
Jan Martin Jansen, Pieter W. M. Koopman, and Rinus Plasmeijer. Efficient interpretation by transforming data types and tatterns to functions. In Trends in Functional Programming, pages 73–90, 2006.
[92]
Aurelien Jarno. gcc-4.3/alpha: -foptimize-sibling-calls generates wrong code. Письмо в рассылку debian-gcc, http://www.mail-archive.com/debian-gcc@lists.debian.org/msg31603.html.
[93]
Mark P. Jones. Type classes with functional dependencies. In Gert Smolka, editor, Programming Languages and Systems, 9th European Symposium on Programming, ESOP 2000, Held as Part of the European Joint Conferences on the Theory and Practice of Software, ETAPS 2000, Berlin, Germany, March 25 - April 2, 2000, Proceedings, volume 1782 of Lecture Notes in Computer Science, pages 230–244. Springer, 2000.
[94]
Simon Peyton Jones, Jean-Marc Eber, and Julian Seward. Composing contracts: an adventure in financial engineering (functional pearl). In ICFP ’00: Proceedings of the fifth ACM SIGPLAN international conference on Functional programming, pages 280–292, New York, NY, USA, 2000. ACM.
[95]
Guy Lewis Steele jr. Debunking the “expensive procedure call” myth or, procedure call implementations considered harmful or, lambda: The ultimate goto. Report A. I. MEMO 443, Massachusetts Institute of Technology, A.I. Lab., Cambridge, Massachusetts, 1977.
[96]
Wolfram Kahl. Beyond pretty-printing: Galley concepts in document formatting combinators. In PADL ’99: Proceedings of the First International Workshop on Practical Aspects of Declarative Languages, pages 76–90, London, UK, 1998. Springer-Verlag.
[97]
Richard A. Kelsy. Tail-recursive stack disciplines for an interpreter. Technical Report NU-CCS-93-03, College of Computer Science, Northeastern University, March 1993.
[98]
Andrew Kennedy. Pickler combinators. J. Funct. Program, 14(6):727–739, 2004.
[99]
Andrew Kennedy and Claudio V. Russo. Generalized algebraic data types and object-oriented programming. SIGPLAN Not., 40(10):21–40, 2005.
[100]
Eugene Kirpichov. digest: Various cryptographic hashes for bytestrings; crc32 and adler32 for now. Пакет на hackage, http://hackage.haskell.org/package/digest.
[101]
Eugene Kirpichov. jarfind: Tool for searching java classes, members and fields in classfiles and jar archives. Пакет на hackage, http://hackage.haskell.org/package/jarfind.
[102]
Edward Kmett. Introduction to monoids (slides). Пост в блоге, http://comonad.com/reader/2009/iteratees-parsec-and-monoid/, 2009.
[103]
Edward Kmett. Iteratees, parsec and monoids. Пост в блоге, http://comonad.com/reader/2009/iteratees-parsec-and-monoid/, August 2009.
[104]
Edward A. Kmett. unboxed-containers: Self-optimizing unboxed sets using view patterns and data families. Пакет на hackage, http://hackage.haskell.org/package/unboxed-containers.
[105]
P. J. Landin. The next 700 programming languages. CACM, 9:157–166, 1966.
[106]
K. Läufer. Type classes with existential types. Journal of Functional Programming, 6(3):485–517, May 1996.
[107]
Konstantin Läufer and Martin Odersky. Polymorphic type inference and abstract data types. ACM Trans. Program. Lang. Syst., 16(5):1411–1430, 1994.
[108]
Keith Lea. A huge gotcha with javascript closures and loops. Пост в блоге, http://joust.kano.net/weblog/archive/2005/08/08/a-huge-gotcha-with-javascript-closures/.
[109]
Roman Leshchinsky. fixpoint: Data types as fixpoints. Пакет на hackage, http://hackage.haskell.org/package/fixpoint.
[110]
HAppS LLC. Happs-server: Web related tools and services. Пакет на hackage, http://hackage.haskell.org/package/HAppS-Server.
[111]
G. Malcolm. Algebraic Data Types and Program Transformation. PhD thesis, Groningen University, 1990.
[112]
Simon Marlow and Simon Peyton Jones. Making a fast curry: push/enter vs. eval/apply for higher-order languages. SIGPLAN Not., 39(9):4–15, 2004.
[113]
Joe Marshall. Серия блог-постов: http://funcall.blogspot.com/2009/04/you-knew-id-say-something.html, http://funcall.blogspot.com/2009/04/you-knew-id-say-something-part-ii.html, http://funcall.blogspot.com/2009/05/you-knew-id-say-something-part-iii.html, http://funcall.blogspot.com/2009/05/you-knew-id-say-something-part-iv.html, http://funcall.blogspot.com/2009/05/you-knew-id-say-something-part-v.html.
[114]
Conor McBride. Faking it: Simulating dependent types in haskell. J. Funct. Program, 12(4&5):375–392, 2002.
[115]
Erik Meijer and Wes Dyer. Reactive framework (rx) under the hood — 1 of 2. Видео на Channel9, http://channel9.msdn.com/shows/Going+Deep/E2E-Erik-Meijer-and-Wes-Dyer-Reactive-Framework-Rx-Under-the-Hood-1-of-2/.
[116]
Erik Meijer and Wes Dyer. Reactive framework (rx) under the hood — 2 of 2. Видео на Channel9, http://channel9.msdn.com/shows/Going+Deep/E2E-Erik-Meijer-and-Wes-Dyer-Reactive-Framework-Rx-Under-the-Hood-2-of-2/.
[117]
Erik Meijer, Maarten Fokkinga, and Ross Paterson. Functional programming with bananas, lenses, envelopes and barbed wire. Lecture Notes in Computer Science, 523:124–??, 1991.
[118]
Alexandre Miquel. An introduction to system f. Слайды доступны по ссылке http://www.pps.jussieu.fr/~miquel/slides/got03-1.pdf.
[119]
Neil Mitchell. Transformation and Analysis of Functional Programs. PhD thesis, University of York, June 2008.
[120]
Neil Mitchell and Colin Runciman. Losing functions without gaining data. In Haskell ’09: Proceedings of the second ACM SIGPLAN symposium on Haskell. ACM, September 2009.
[121]
Neil Mitchell and Colin Runciman. Losing functions without gaining data. Слайды, http://community.haskell.org/~ndm/downloads/slides-losing_functions_without_gaining_data-03_sep_2009.pdf, September 2009.
[122]
Jun Mukai. Haskellnet: network related libraries such as pop3, smtp, imap. Пакет на hackage, http://hackage.haskell.org/package/HaskellNet.
[123]
James Noble. Arguments and results. Comput. J, 43(6):439–450, 2000.
[124]
Chris Okasaki. Binomial queues as a nested type. Пост в блоге от 22 октября 2009 г., http://okasaki.blogspot.com/2009/10/binomial-queues-as-nested-type.html.
[125]
Chris Okasaki. Edisoncore: A library of efficent, purely-functional data structures (core implementations). Пакет на hackage, http://hackage.haskell.org/package/EdisonCore.
[126]
Chris Okasaki. Functional pearl: Even higher-order functions for parsing or why would anyone ever want to use a sixth-order function? Journal of Functional Programming, 8(2):195–199, 1998.
[127]
Chris Okasaki. Purely Functional Data Structures. Cambridge University Press, 1999.
[128]
Chris Okasaki. Breadth-first numbering: Lessons from a small exercise in algorithm design. In ICFP, pages 131–136, 2000.
[129]
Runar Oli. Structural pattern matching in java. Пост в блоге, http://apocalisp.wordpress.com/2009/08/21/structural-pattern-matching-in-java/.
[130]
Luke Palmer. graphics-drawingcombinators: A functional interface to 2d drawing in opengl. Пакет на hackage, http://hackage.haskell.org/package/graphics-drawingcombinators.
[131]
Christine Paulin-Mohring. Inductive definitions in the system coq — rules and properties. In TLCA ’93: Proceedings of the International Conference on Typed Lambda Calculi and Applications, pages 328–345, London, UK, 1993. Springer-Verlag.
[132]
Lawrence C. Paulson. Constructing recursion operators in intuitionistic type theory. Journal of Symbolic Computation, 2(4):325–355, December 1986.
[133]
Tomáš Petříček. Reactive programming (i.) — first class events in f#. Пост в блоге, http://tomasp.net/blog/reactive-i-fsevents.aspx.
[134]
Tomáš Petříček. Reactive programming (ii.) — introducing reactive linq. Пост в блоге, http://tomasp.net/articles/reactive-ii-csevents.aspx.
[135]
Simon Peyton Jones. A pretty printer library in haskell. Часть стандартной библиотеки GHC, http://research.microsoft.com/en-us/um/people/simonpj/downloads/pretty-printer/pretty.html.
[136]
Simon Peyton-Jones, Mark Jones, and Erik Meijer. Type classes: an exploration of the design space. In J. Launchbury, editor, Haskell workshop, Amsterdam, 1997.
[137]
Simon L. Peyton Jones. The Implementation of Functional Programming Languages. Prentice–Hall, 1987.
[138]
Robert Pickering. Beyond Foundations of F# — Asynchronous Workflows. Статья на infoq, http://www.infoq.com/articles/pickering-fsharp-async.
[139]
Benjamin C. Pierce. Types and Programming Languages. The MIT Press, Cambridge, Massachusetts, 2002.
[140]
Dan Piponi. An approach to algorithm parallelisation. Пост в блоге, http://blog.sigfpe.com/2008/11/approach-to-algorithm-parallelisation.html, November 2008.
[141]
Dan Piponi. Моноиды в haskell и их использование (перевод Кирилла Заборского). Практика функционального программирования, 1, July 2009.
[142]
Matthes Podwysocki. F# first class events — composing events until others. Пост в блоге, http://codebetter.com/blogs/matthew.podwysocki/archive/2009/10/15/f-first-class-events-composing-events-until-others.aspx.
[143]
Bernie Pope. hinvaders: Space invaders. Пакет на hackage, http://hackage.haskell.org/package/hinvaders.
[144]
Reiner Pope. first-class-patterns: First class patterns and pattern matching, using type families. Пакет на hackage, http://hackage.haskell.org/package/first-class-patterns.
[145]
Reiner Pope. Pattern combinators. Серия блог-постов, http://reinerp.wordpress.com/category/pattern-combinators/.
[146]
Mark Reinhold. Project lambda: Straw-man proposal. http://cr.openjdk.java.net/~mr/lambda/straw-man/.
[147]
Morten Rhiger. Type-safe pattern combinators. J. Funct. Program, 19(2):145–156, 2009.
[148]
Alexey Rodriguez, Stefan Holdermans, Andres Löh, and Johan Jeuring. multirec: Generic programming for families of recursive datatypes. Пакет на hackage, http://hackage.haskell.org/package/multirec.
[149]
E. Scholz. A Monad of Imperative Streams. Ullapool, Scotland, July 1996. Department of Computing Science, University of Glasgow.
[150]
Enno Scholz. Imperative streams —a monadic combinator library for synchronous programming. In ICFP ’98: Proceedings of the third ACM SIGPLAN international conference on Functional programming, pages 261–272, New York, NY, USA, 1998. ACM.
[151]
Moses Schönfinkel. Über die bausteine der mathematischen logik. Math. Annalen, 92:305–316, 1924. An English translation appears in From Frege to Godel, edited by Jean van Heijenoort (Harvard Univ. Press, 1967), pages 355-366.
[152]
Moses Schönfinkel. On the building blocks of mathematical logic. pages 355–366, 1967. A Source Book in Mathematical Logic, 1879–1931.
[153]
Tom Schrijvers, Simon L. Peyton Jones, Manuel M. T. Chakravarty, and Martin Sulzmann. Type checking with open type functions. In James Hook and Peter Thiemann, editors, Proceeding of the 13th ACM SIGPLAN international conference on Functional programming, ICFP 2008, Victoria, BC, Canada, September 20-28, 2008, pages 51–62. ACM, 2008.
[154]
Ganesh Sittampalam. Darcs and gadts. Видео и слайды в PDF, http://www.londonhug.net/2008/02/02/video-darcs-and-gadts/.
[155]
Jon Skeet. The beauty of closures. Глава из книги «C# in depth», http://csharpindepth.com/Articles/Chapter5/Closures.aspx.
[156]
Leon P Smith. Why object-oriented languages need tail calls. Ветка на форуме Lambda the Ultimate, http://lambda-the-ultimate.org/node/3702.
[157]
Morten H. Sørensen and Robert Glück. An algorithm of generalization in positive supercompilation. In John Lloyd, editor, Proceedings of the International Symposium on Logic Programming, pages 465–479, Cambridge, December 1995. MIT Press.
[158]
Guy Steele. Why object-oriented languages need tail calls. Пост в блоге Project Fortress, http://projectfortress.sun.com/Projects/Community/blog/ObjectOrientedTailRecursion.
[159]
Guy Steele and Gerald Jay Sussman. Lambda papers. Серия статей, доступны по адресу http://library.readscheme.org/page1.html.
[160]
Roger Stokes. Learning j: Chapter 8. composing verbs. http://www.jsoftware.com/help/learning/08.htm.
[161]
Christopher Strachey. Fundamental concepts in programming languages. Higher Order Symbol. Comput., 13(1-2):11–49, 2000.
[162]
S. Doaitse Swierstra and Olaf Chitil. Linear, bounded, functional pretty-printing. J. Funct. Program., 19(1):1–16, 2009.
[163]
Don Syme. Introducing f# asynchronous workflows. Пост в блоге, http://blogs.msdn.com/dsyme/archive/2007/10/11/introducing-f-asynchronous-workflows.aspx.
[164]
Don Syme. What’s new in f# — asynchronous workflows. Видео на Channel9, http://channel9.msdn.com/posts/Charles/Don-Syme-Whats-new-in-F-Asynchronous-Workflows-and-welcome-to-the-NET-family/.
[165]
Don Syme. F# active patterns. http://blogs.msdn.com/dsyme/attachment/2044281.ashx, 2007.
[166]
Hideyuki Tanaka and Takayuki Muranushi. Monadius: 2-d arcade scroller. Пакет на hackage, http://hackage.haskell.org/package/Monadius.
[167]
Mads Tofte and Jean-Pierre Talpin. Region-based memory management. Inf. Comput., 132(2):109–176, 1997.
[168]
Mark Tullsen. First class patterns. Lecture Notes in Computer Science, 1753:1–??, 2000.
[169]
Franklyn A. Turbak and David K. Gifford. Design Concepts in Programming Languages. The MIT Press, 2008.
[170]
Anton van Straaten. Коан об объектах и замыканиях. http://people.csail.mit.edu/gregs/ll1-discuss-archive-html/msg03277.html.
[171]
Philip Wadler. How to replace failure by a list of successes. In Proc. of a conference on Functional programming languages and computer architecture, pages 113–128, New York, NY, USA, 1985. Springer-Verlag New York, Inc.
[172]
Philip Wadler. Views: A way for pattern matching to cohabit with data abstraction. In POPL, pages 307–313, 1987.
[173]
Philip Wadler. A prettier printer. Journal of Functional Programming, 1999.
[174]
Philip Wadler and Stephen Blott. How to make ad-hoc polymorphism less ad hoc. In 16’th ACM Symposium on Principles of Programming Languages, Austin, Texas, January 1989.
[175]
Mitchell Wand. Continuation-based program transformation strategies. Journal of the ACM, 27:164–180, 1980.
[176]
Louis Wasserman. Triemap: Automatic type inference of generalized tries. Пакет на hackage, http://hackage.haskell.org/package/TrieMap.
[177]
Stefan Wehr and Manuel M. T. Chakravarty. Ml modules and haskell type classes: A constructive comparison. In G. Ramalingam, editor, APLAS, volume 5356 of Lecture Notes in Computer Science, pages 188–204. Springer, 2008.
[178]
Hongwei Xi, Chiyan Chen, and Gang Chen. Guarded recursive datatype constructors. SIGPLAN Not., 38(1):224–235, 2003.
[179]
Brent Yorgey. Typeclassopedia. The Monad.Reader, 13, March 2009.
[180]
Кирпичёв Е. antro: a line-level profiler for ant build scripts. Проект на sourceforge, sourceforge.net/projects/antro.
[181]
Кирпичёв Е. Coq. Доклад на собрании SPb Haskell User Group, 24 октября 2008 г., http://spbhug.folding-maps.org/wiki/Coq.
[182]
Кирпичёв Е. Чисто функциональные структуры данных (ч.2). Доклад на собрании SPb Haskell User Group, 11 ноября 2007 г., http://spbhug.folding-maps.org/wiki/FunctionalDataStructures2, 2007.
[183]
Кирпичёв Е. Приемы программирования на java: Повышение читаемости кода и функциональное программирование. Доступно по адресу http://www.rsdn.ru/article/java/JavaFP.xml, 2008.
[184]
Кирпичёв Е. Функциональное программирование в java. Слайды к докладу на математическо-механическом факультете СПбГУ, http://spbhug.folding-maps.org/wiki/EugeneKirpichov?action=AttachFile&do=get&target=JavaFP_ru.ppt, 2008.
[185]
Кирпичёв Е. Изменяемое состояние: опасности и борьба с ними. Практика функционального программирования, 1, July 2009.
[186]
Кирпичёв Е. Курс лекций «Функциональное программирование» для студентов АФТУ. http://spbhug.folding-maps.org/wiki/SICP_Course, 2009.
[187]
Хювёнен, Э. и Сеппянен, Й. Мир Лиспа. 1990.
[188]
Царёв О. Парсер заголовочных файлов Си на python. Пост в блоге, http://zabivator.livejournal.com/359491.html.

1
Не стоит из примера делать вывода, что аналогичный по функциональности код на языке без хорошего алгоритма вывода типов был бы действительно настолько раздут: скорее, код пришлось бы написать как-нибудь по-другому, менее просто и ясно, чем в первом из примеров на Haskell. Хороший алгоритм вывода позволяет, по крайней мере, не отбрасывать некоторые красивые идиомы только из-за того, что с ними не справится вывод типов.
2
То есть, содержащего некое «нулевое» значение, например, null.
3
Впрочем, в ряде задач переменные с динамической областью видимости бывают полезны; они поддерживаются, например, языком Common Lisp.
4
Перевод «фунарг-проблема» используется в книге «Мир Лиспа» [187].
5
Автору не известен устоявшийся перевод этого термина на русский язык, приведенный перевод придуман специально для данной статьи.
6
На эту тему существует дзен-коан [170].
7
Некоторые (в основном, в шутку) утверждают, что вместо термина «карринг» следует употреблять термин «шейнфинкелизация».
8
А также позволяет «скомпилировать» регулярное выражение, так что многократные применения результата matchesRegexp “-?[0-9]+” к множеству строк s1,s2,…выполняются намного эффективнее, чем многократные вычисления matchesRegexp “-?[0-9]+” s1, matchesRegexp “-?[0-9]+”  s2 и т. п.
9
Конечно же, необязательно фиксировать аргументы какими-то «конкретными» значениями (литералами).
10
А также в теоретико-категорное определение декартово замкнутой категории, где при помощи curry фактически вводится определение оператора «применение функции к аргументу». К счастью, для понимания материала этой статьи знакомство с теорией категорий не требуется.
11
Статья появилась еще в то время, когда компиляторы были настолько неразвиты, что вызовы процедур вообще недолюбливали из-за низкой производительности. Прошло более 30 лет, однако до сих пор многие программисты и даже разработчики языков не знакомы с доводами, приведенными в этой статье.
12
О том, что будет, если нарушить это требование, можно прочитать в посте Мэттью Флэтта «Getting rid of set-car! and set-cdr!» ([66]).
13
По субъективному мнению автора, этот пост можно смело причислить к шедеврам программирования.
14
Заметим, что это не исключает возможности написания всегда завершающегося компилятора одного Тьюринг-полного языка в другой.
15
Конечно, речь здесь и далее идет о тотальности процедуры при условии тотальности и завершаемости используемых ею других процедур.
16
Еще более хорошим тоном считается по возможности избегать явного использования рекурсии и использовать ее лишь в комбинаторах (см. 14) общего назначения, таких как map, fold и т. п. Речь идет о структурной рекурсивности этих комбинаторов.
17
Она идиоматична исключительно с точки зрения своей распространенности. Разумеется, ее практическая полезность близка к нулю из-за низкой эффективности, и сортировку подобным образом на функциональных языках не реализуют.
18
Не следует путать понятие типового конструктора с понятием конструктора алгебраического типа (см. 9).
19
Автор не является оголтелым фанатом регулярных выражений и не предлагает использовать их в каждой задаче. Однако в своей области применения (проверка принадлежности и разбор строк достаточно простых языков — например, задание правил токенизации в лексических анализаторах) они превосходно иллюстрируют тему данной статьи.
20
О том, как достичь компромисса между эффективностью и универсальностью в случае преобразований координат можно прочитать, например, в лекции номер 4 «Абстракция данных» курса «Функциональное программирование» Евгения Кирпичёва ([186]).
21
Если изучить стр. 9 статьи детально, то выясняются еще более шокирующие подробности соотношения продуктивности различных языков, заставляющие усомниться в корректности методики проведенного тестирования даже самых ярых сторонников ФП. Тем не менее, стоит ознакомиться со статьей, чтобы увидеть разницу в подходах к разработке на различных языках.

Этот документ был получен из LATEX при помощи HEVEA