ДУШКИН Роман Викторович
[email protected] http://roman-dushkin.narod.ru/
ФП 02005-02 01 Джон Хьюз (John Hughes)
Взам. инв. № Инв. № дубл.
Подп. и дата
СИЛЬНЫЕ СТОРОНЫ ФУНКЦИОНАЛЬНОГО ПРОГРАММИРОВАНИЯ
Chalmers University of Technology
[email protected]
Инв. № подл.
Подп. и дата
http://www.cs.chalmers.se/~rjmh/
2006
Копирова
Формат
АННОТАЦИЯ Поскольку программное обеспечение постоянно усложняется, повышается и необходимость в улучшении его структуры. Хорошо структурированное программное обеспечение проще пишется и легче отлаживается, оно предоставляет набор многократно используемых модулей, уменьшает затраты на программирование в будущем. Традиционные языки имеют концептуальные ограничения на организацию модульной структуры. Функциональные языки снимают эти ограничения. В статье рассмотрено использование двух особенностей функциональных языков, практическое способствующих повышению модульности: функций более высокого порядка и ленивых вычислений. В качестве примеров используются списки и деревья, несколько численных алгоритмов, а также альфа-бета эвристика (алгоритм из области искусственного интеллекта, используемый в игровых программах). Так как модульность — ключ к успешному программированию, функциональные языки жизненно важны для реального мира.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Ключевые слова: функциональное программирование, функции высшего порядка, ленивые вычисления, модульность.
ФП 02005-02 01 ИзмЛист № докум. Разраб. Душкин Р. Пров. Н. контр. Утв.
Подп. Дата
Лит. Функциональное программирование Копирова
Лист Листов 2 27
Формат
ФП 02005-02 01
СОДЕРЖАНИЕ 1. АНАЛОГИЯ СО СТРУКТУРНЫМ ПРОГРАММИРОВАНИЕМ........................................6 2. СВЯЗЫВАНИЕ ФУНКЦИЙ ....................................................................................................8 3. СВЯЗЫВАНИЕ ПРОГРАММ................................................................................................12 3.1. Вычисление квадратного корня методом Ньютона - Рафсона....................................12 3.2. Численное дифференцирование .....................................................................................14 3.3. Численное интегрирование.............................................................................................16 4. ПРИМЕР ИЗ ИСКУССТВЕННОГО ИНТЕЛЛЕКТА ..........................................................19 5. ЗАКЛЮЧЕНИЕ.......................................................................................................................25
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
6. ЛИТЕРАТУРА ........................................................................................................................26
Лист
ФП 02005-02 01 ИзмЛист № докум.
3
Подп. Дата Копирова
Формат
ФП 02005-02 01
ВВЕДЕНИЕ Эта статья является попыткой демонстрации «реальному миру» того, что функциональное программирование жизненно важно, и призвана помочь программистам полностью использовать его преимущества. В ней также показывается, что это за преимущества.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Функциональное программирование называется так, потому что программа полностью состоит из функций. Сама программа тоже является функцией, которая получает исходные данные в качестве аргумента, а выходные данные выдаёт как результат. Как правило, основная функция определена в терминах других функций, которые в свою очередь определены в терминах ещё большего количества функций, вплоть до функций — примитивов языка на самом нижнем уровне. Эти функции очень похожи на обычные математические функции, и в этой статье будут определены обычными уравнениями. Для демонстрации используется язык программирования Haskell, но примеры должны быть понятны и без знания функциональных языков. Особенности и преимущества функционального программирования обычно излагаются следующим образом. Функциональные программы не содержат операторов присваивания, а переменные, получив однажды значение, никогда не изменяются. Более того, функциональные программы вообще не имеют побочных эффектов. Обращение к функции не вызывает иного эффекта кроме вычисления результата. Это устраняет главный источник ошибок и делает порядок выполнения функций несущественным: так как побочные эффекты не могут изменять значение выражения, оно может быть вычислено в любое время. Программист освобождается от бремени описания потока управления. Поскольку выражения могут быть вычислены в любое время, можно свободно заменять переменные их значениями и наоборот, то есть программы «прозрачны по ссылкам». Эта прозрачность ссылок делает функциональные программы более удобными для математической обработки, по сравнению с общепринятыми аналогами. Приведенный список «преимуществ» очень хорош, но ни для кого не является сюрпризом, что функциональное программирование не воспринимается всерьез. Он много говорит о том, чего нет в функциональном программировании (нет присваивания, побочных эффектов, потоков управления) но ничего о том, чем же оно является. Функциональный программист смотрится как средневековый монах, отвергающий удовольствия жизни в надежде, что это сделает его добродетельным. Для тех, кто заинтересован в материальных выгодах, эти «преимущества» не очень убедительны. Сторонники функционального стиля утверждают, что имеются большие материальные выгоды, и что функциональный программист — на порядок более производителен, чем его обычный (императивный) коллега, потому что функциональные программы на порядок короче. Но почему же такое возможно? Единственная
Лист
ФП 02005-02 01 ИзмЛист № докум.
4
Подп. Дата Копирова
Формат
ФП 02005-02 01
маловероятная причина, которую можно предположить на основе анализа этих «преимуществ» заключается в том, что обычные программы на 90% состоят из операторов присваивания, а в функциональных программах они могут быть опущены! Но это смешно. Если бы исключение оператора присваивания принесло такие огромные выгоды, то программисты на Фортране сделали бы это лет двадцать назад. Здравый смысл подсказывает, что невозможно сделать язык более мощным, выбрасывая из него некоторые свойства, независимо от того, насколько плохими они могут быть. Даже функциональный программист не должен быть удовлетворен этими, так называемыми, преимуществами, потому что они не оказывают никакой помощи в использовании мощности функциональных языков. Он не может писать программы, которые частично лишены операторов присваивания, или частично прозрачны по ссылкам. Здесь нет никакого критерия качества программы, и поэтому никакого идеала, к которому надо стремиться.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Ясно, что такая характеристика функционального программирования неадекватна. Мы должны найти, что-то вместо неё — то, что не только объяснит мощь функционального программирования, но и показывает, к чему программист должен стремиться.
Лист
ФП 02005-02 01 ИзмЛист № докум.
5
Подп. Дата Копирова
Формат
ФП 02005-02 01
1. АНАЛОГИЯ СО СТРУКТУРНЫМ ПРОГРАММИРОВАНИЕМ
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Полезно провести аналогию между функциональным и структурным программированием. Когда-то характеристики и преимущества структурного программирования излагались более или менее следующим образом. Структурные программы не содержат операторов goto. Блоки в структурной программе не имеют несколько входов или выходов. Структурные программы лучше поддаются математической обработке, чем их неструктурные аналоги. Эти «преимущества» структурного программирования очень похожи по духу на «преимущества» обсуждённые ранее. Они по существу малозначимы, и чаще всего вели ко многим бесплодным спорам относительно «необходимости goto» и всего остального. Ясно, что эти свойства структурных программ, хоть и полезны, но не отражают существа вопроса. Наиболее важное различие между структурным и неструктурным подходом в том, что структурные программы, разработаны модульным способом. Модульность ведёт к повышению производительности при создании проекта. Прежде всего, маленькие модули могут быть закодированы быстро и легко. Во-вторых, универсальные модули могут многократно использоваться, что приводит к более быстрому построению последующих программ. В-третьих, модули программы могут быть проверены независимо, что помогает уменьшить время, потраченное на отладку. Отсутствие goto и т.п. немногим помогает этому. Оно помогает «программированию в малом», в то время как модульное проектирование помогает «программированию в большом». Таким образом, можно наслаждаться выгодами от структурного программирования на Фортране или ассемблере, даже если это требует больших усилий. Сейчас общеизвестно, что модульная разработка — ключ к успешному программированию, и языки типа Modula-2 [Wir82] , Ada [oD80] и Standard ML [MTH90] включают конструкции, предназначенные для улучшения модульности. Однако имеется очень важный момент, который часто опускают. При создании модульной программы, чтобы решить задачу, мы делим её на подзадачи, затем решаем подзадачи и объединяем решения. Методы, которыми можно разделить первоначальную задачу, зависят непосредственно от методов, которыми можно связать решения вместе. Поэтому, чтобы увеличить способность к концептуальному разбиению задачи на модули, нужно обеспечить новые виды связующих элементов в языке программирования. Сложные правила формирования контекста, и условия, обеспечивающие раздельную трансляцию, помогают только как дополнительные канцелярские мелочи; они не предлагают никаких концептуально новых инструментальных средств для решения задач декомпозиции.
Лист
ФП 02005-02 01 ИзмЛист № докум.
6
Подп. Дата Копирова
Формат
ФП 02005-02 01
Оценить важность связующих элементов можно по аналогии с плотницкими работами. Стул можно сделать довольно легко, если изготовить части — сиденье, ножки, спинку и т.д. и собрать их вместе правильным способом. Но эта возможность зависит от способности делать соединения и наличия столярного клея. При отсутствии такой способности, единственный способ сделать стул состоит в том, чтобы вырезать его целиком из куска древесины — намного более трудная задача. Этот пример демонстрирует, и огромную мощь модульности и важность использования подходящих связующих элементов. Теперь вернёмся к функциональному программированию. Мы приведём доводы в пользу того, что функциональные языки предоставляют два новых, очень важных видов связующих элементов. Мы дадим примеры программ, которые можно разбить на модули новыми способами, и таким образом очень упростить.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Причина мощи функционального программирования в том, что оно улучшает модульность. Цель, к которой функциональные программисты должны стремиться: меньшие по размеру, более простые и универсальные модули, связанные при помощи новых связующих элементов, которые мы опишем ниже.
Лист
ФП 02005-02 01 ИзмЛист № докум.
7
Подп. Дата Копирова
Формат
ФП 02005-02 01
2. СВЯЗЫВАНИЕ ФУНКЦИЙ Первый из двух новых видов связующих элементов позволяет связывать простые функции в более сложные. Это можно проиллюстрировать на примере простой задачи обработки списков: сложении его элементов. Мы определяем списки следующим образом: data [x] = [] | x : [x]
Это означает, что список иксов — это либо пустой список [], либо конструкция из элемента x и другого списка иксов. Список x:xs, представляет собой список, первый элемент которого — x, а последующие — элементы другого списка xs. Элемент x здесь может замещать любой тип — например, если x «целочисленный», то определение говорит, что список целых чисел является либо пустым, либо составлен из целого числа и другого списка целых чисел. Следуя обычным обозначениям, мы записываем списки, просто перечисляя их элементы в квадратных скобках. Например
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
[1] означает 1:[] [1,2,3] означает 1:2:3:[]
Элементы списка могут быть просуммированы рекурсивной функцией sum. Она должна быть определена для двух видов параметра: пустого списка и конструктора. Так как сумма пустого множества чисел равна нулю, мы определяем: sum [] = 0
А так как сумма может быть рассчитана сложением первого элемента списка с суммой остальных, мы можем определить: sum num:list = num + sum list
Исследуя это определение, мы видим, что только выделенные части («0» и «+») определяют вычисление суммы. Это значит, что вычисление суммы может быть «модуляризовано» связыванием общего рекурсивного образца и выделенных частей. Этот рекурсивный образец традиционно называется свёрткой (reduce), так что сумма может быть выражена как sum = reduce (+) 0
Определение свёртки, может быть получено параметризацией определения sum: reduce f x [] = x reduce f x (a:l) = f a (reduce f x) l
Здесь мы выделили цепочку reduce f x, чтобы прояснить, что она заменяет sum. Функция от 3-х аргументов типа reduce, применяемая только к 2-м, считается функцией 1-го параметра. И вообще, функция от n параметров, применённая к m (m < n), будет функцией n – m оставшихся аргументов. Мы будем следовать этому соглашению и в дальнейшем.
Лист
ФП 02005-02 01 ИзмЛист № докум.
8
Подп. Дата Копирова
Формат
ФП 02005-02 01
«Модуляризовав», таким образом sum, мы можем пожинать плоды, многократного использования. Наиболее интересная часть функции — это reduce, которая может использоваться, и для функции умножения элементов списка: product = reduce (*) 1
Или, чтобы проверить, является ли какая-нибудь переменная из списка булевых истинной: anytrue = reduce (||) False
Или, что они все истинны: alltrue = reduce (&&) True
Один из способов понять использование reduce f a в качестве функции, заключается в мысленной замене всех вхождений «:» в списке на f, и всех вхождений «[]» на a. Например, список 1 : 2 : 3 : []: reduce (+) 0 преобразует в 1 + 2 + 3 + 0 reduce (*) 1 преобразует в 1* 2 * 3 * 1
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Теперь очевидно что reduce (:) [] просто копирует список. Так как список можно добавить к другому, помещая его элементы спереди, находим: append a b = reduce (:) b a
Функцию для удвоения всех элементов списка можно описать так doubleall = reduce doubleandcons [] doubleandcons num list = 2 * num : list
В свою очередь, doubleandcons может быть модуляризована дальше сначала в doubleandcons = fandcons double double n = 2 * n fandcons f el list = (f el) : list
и затем fandcons f = (:) . f
где «.» — стандартный оператор функциональной композиции, определённый как (f . g) h = f (g h)
Можно убедиться, что новое определение fandcons правильно, применяя его к некоторым параметрам: fandcons f el list = (f el): list
заключительная версия doubleall = reduce ((:) . double) []
Инв. № подл.
Дальнейшей модуляризацией мы достигаем
Лист
ФП 02005-02 01 ИзмЛист № докум.
9
Подп. Дата Копирова
Формат
ФП 02005-02 01
doubleall = map double map f = reduce ((:). f) []
Функция map применяет любую функцию f ко всем элементам списка — пример ещё одной общеполезной функции. Мы можем даже написать функцию, для сложения всех элементов матрицы, представленной как список списков. summatrix = sum . map sum
Функция summatrix использует (map sum), чтобы просуммировать все строки, затем крайняя левая sum складывает результаты, и выдаёт сумму целой матрицы. Этих примеров должно быть достаточно, чтобы убедить читателя, что модуляризация может проходить длительный путь. Представляя простую функцию (sum) в виде комбинаций «функций высшего порядка» и некоторых простых параметров, мы получили фрагмент (reduce), который без больши́х усилий может использоваться для программирования многих других функций обработки списков. Мы не обязаны ограничиваться только функциями над списками. В качестве другого примера, рассмотрим тип данных «упорядоченное дерево», определенный как:
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
data Treeof x = Node x [Treeof x]
Это определение говорит, что дерево элементов x — узел, с меткой, которая является x, и список поддеревьев, которые являются также деревьями элементов x. Вместо того, чтобы рассматривать пример и абстрагироваться от него к функции более высокого порядка, мы будем непосредственно строить функцию redtree аналогичную reduce. Напомним, что reduce, получала два параметра — один, чтобы заменить «:», и другой, чтобы заменить «[]». Так как деревья формируются с использованием Node, «:» и «[]», redtree должна получить три параметра, чтобы заменить каждый из них. Деревья и списки имеют различные типы, поэтому мы должны определить две функции, по одной для каждого типа. Мы определяем: redtree f g a (Node x subtrees) = f x (redtree' f g a subtrees) redtree' f g a (x:rest) = g (redtree f g a x) (redtree' f g a rest) redtree' f g a [] = a
Связывая redtree с другими функциями можно определить много интересных функций. Например, можно сложить все элементы в дереве чисел используя: sumtree = redtree (+) (+) 0
Список всех элементов дерева можно получить, используя: labels = redtree (:) append []
Наконец, можно определять функцию, аналогичную map, которая применяет функцию f ко всем элементам дерева:
Инв. № подл.
maptree f = redtree (Node . f) (:) []
Лист
ФП 02005-02 01 ИзмЛист № докум.
10
Подп. Дата Копирова
Формат
ФП 02005-02 01
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Всё это может быть достигнуто потому, что функциональные языки позволяют выразить функции, неделимые в обычных языках программирования, в виде комбинации частей: общей функции высшего порядка и некоторой частной специализированной функции. Такие функции высшего порядка позволяют очень легко запрограммировать многие операции. Всякий раз, когда определён новый тип данных, для его обработки должны быть написаны функции высшего порядка. Это упрощает работу с типом данных и локализует знания относительно подробностей его представления. Лучшая аналогия с обычным программированием — расширяемые языки. Если бы любой язык программирования можно было расширять новыми управляющими структурами всякий раз, когда потребуется...
Лист
ФП 02005-02 01 ИзмЛист № докум.
11
Подп. Дата Копирова
Формат
ФП 02005-02 01
3. СВЯЗЫВАНИЕ ПРОГРАММ
Функциональные языки обеспечивают решение этой проблемы. Две программы F и G выполняются вместе строго синхронно. Программа F запускается только тогда, когда G пытается прочитать некоторый ввод, и выполняется ровно столько, чтобы предоставить данные, который пытается читать G. После этого F приостанавливается, и выполняется G, до тех пор, пока вновь не попытается прочитать следующую группу входных данных. Если G заканчивается, не прочитав весь вывод F, то F прерывается. Программа F может быть даже незавершающейся программой, создающей бесконечный вывод, так как она будет остановлена, как только завершится G. Это позволяет отделить условия завершения от тела цикла, что является мощным средство модуляризации.
3.1. Вычисление квадратного корня методом Ньютона - Рафсона Мы проиллюстрируем мощь ленивых вычислений, программируя некоторые численные алгоритмы. Прежде всего, рассмотрим алгоритм Ньютона - Рафсона для вычисления квадратного корня. Этот алгоритм вычисляет квадратный корень числа z, начиная с начального приближения a0. Он уточняет это значение на каждом последующем шаге, используя правило:
Инв. № подл.
Взам. инв. № Инв. № дубл.
Этот метод называется «ленивыми вычислениями» так как F выполняется настолько редко, насколько это возможно. Он позволяет осуществить модуляризацию программы как генератора, который создаёт большое количество возможных ответов, и селектора, который выбирает подходящие. Некоторые другие системы позволяют программам выполнятся вместе подобным способом, но только функциональные языки использует ленивые вычисления однородно при каждом обращении к функции, позволяя модуляризовать таким образом любую часть программы. Ленивые вычисления, возможно, наиболее мощный инструмент для модуляризации в наборе функционального программиста.
Подп. и дата
Подп. и дата
Другой новый вид связующих элементов, который используют функциональные языки, допускает связывать целые программы. Напомним, что функциональная программа — просто функция из входных данных в её выходные данные. Если F и G — такие программы, то (G . F) — программа которая, вычисляет G (F input). Программа F вычисляет свой вывод, который используется как ввод, для программы G. Традиционно, это можно осуществить, сохраняя вывод F во временный файл. Проблема состоит в том, что временный файл может занять так много памяти, что непрактично связывать программы подобным образом.
Лист
ФП 02005-02 01 ИзмЛист № докум.
12
Подп. Дата Копирова
Формат
ФП 02005-02 01
an + 1 = (an + z/an) / 2
(3.1)
Если приближения сходятся к некоторому пределу a, то a = (a + z/a) / 2 , то есть a * a = z или a = squareroot (z) Фактически сведение к пределу проходит быстро. Программа проводит проверку на точность (eps) и останавливается, когда два последовательных приближения отличаются меньше чем на eps. При императивном подходе алгоритм обычно программируется следующим образом: x = a0; do { y = x; x = (x + z / x) / 2; } while ( abs (x - y) < eps) // теперь x = квадратному корню из z
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Эта программа неделима на обычных языках. Мы выразим её в более модульной форме, используя ленивые вычисления, и затем покажем некоторые другие применения полученным частям. Так как алгоритм Ньютона - Рафсона вычисляет последовательность приближений, естественно представить это в программе явно списком приближений. Каждое приближение получено из предыдущего функцией: next z x = (x + z / x) / 2
То есть (next z) — функция, отображающая каждое приближение в следующее. Обозначим эту функцию f, тогда последовательность приближений будет: [a0, f a0, f (f a0), f (f (f a0)), ...]. Мы можем определить функцию, вычисляющую такую последовательность: iterate f x = x : iterate f (f x)
Тогда список приближений можно вычислить так: iterate (next z) a0
Здесь iterate — пример функции с «бесконечным» выводом — но это не проблема, потому что фактически будет вычислено не больше приближений, чем требуется остальным частям программы. Бесконечность — только потенциальная: это означает, что любое число приближений можно вычислить, если потребуется, iterate сама по себе не содержит никаких ограничений. Остаток программы — функция within, которая берёт допуск и список приближений и, просматривая список, ищет два последовательных приближения, отличающихся не более чем на данный допуск.
Инв. № подл.
within eps (a:b:rest) = if abs (a - b) <= eps
Лист
ФП 02005-02 01 ИзмЛист № докум.
13
Подп. Дата Копирова
Формат
ФП 02005-02 01
then b else within eps (b : rest)
Собирая части, получаем: sqrt a0 eps z = within eps (iterate (next z) a0)
Теперь, когда мы имеем части программы поиска квадратного корня, мы можем попробовать объединить их различными способами. Одна из модификаций, которую мы могли бы пожелать, заключается в использовании относительной погрешности вместо абсолютной. Она больше подходит как для очень малых чисел (когда различие между последовательными приближениями маленькое), так и для очень больших (при округлении ошибки могут быть намного большими, чем допуск). Необходимо определить только замену для within: relative eps (a:b:rest) =
if abs (a - b) <= eps * abs (b) then b else relative eps (b:rest)
Теперь можно определить новую версию sqrt: relativesqrt a0 eps z = relative eps (iterate (next z) a0)
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Нет необходимости переписывать часть, которая генерирует приближения.
3.2. Численное дифференцирование Мы повторно использовали iterate при генерации последовательности приближений в вычислениях квадратного корня. Конечно же, можно многократно использовать within и relative в любых численных алгоритмах, которые генерирует последовательность приближений. Сделаем это в алгоритме численного дифференцирования. Производная функции в точке — наклон графика функции в этой точке. Её можно легко найти, определив значение функции в данной точке и в другой точке поблизости и вычислив наклон прямой линии между двумя точками. Предполагается, что, если две точки находятся достаточно близко, то график функции между ними не будет слишком изгибаться. Это дает определение: easydiff f x h = (f (x + h) - f x) / h
Чтобы получить хорошее приближение, значение h должно быть очень маленьким. К сожалению, если h слишком маленькое, то два значения f (x + h) и f (x) — очень близки друг другу, так что ошибка округления при вычитании может заглушать результат. Как правильно выбрать значение h? Одно решение этой дилеммы — вычислять последовательность приближений со всё меньшими и меньшими значениями h, начиная с разумно большого. Такая последовательность должна сходиться к значению производной, но станет безнадёжно неточной в конечном счёте из-за ошибок округления.
Лист
ФП 02005-02 01 ИзмЛист № докум.
14
Подп. Дата Копирова
Формат
ФП 02005-02 01
Если (within eps) используется, чтобы выбрать первое приближение, которое достаточно точно, то риск ошибки округления, воздействующей на результат можно уменьшить. Мы нуждаемся в функции, вычисляющей последовательность: differentiate h0 f x = map (easydiff f x) (iterate halve h0) halve x = x / 2
Где h0 — начальное значение h, последовательные значения получены повторным делением на два. С помощью этой функции, производная в любой точке может быть вычислена так: within eps (differentiate h0 f x)
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Но это решение не вполне удовлетворительно, потому что последовательность приближений сходится довольно медленно. Немного простой математики может нам помочь. Элементы последовательности могут быть выражены как правильный ответ + погрешность, относящаяся к h. Можно показать теоретически, что член погрешности грубо пропорционален степени h. Пусть A — правильный ответ и B * hn — ошибка. Так как каждое последующее приближение вычислено, используя значение h вдвое меньшее того, что было использовало для предыдущего, любые два последовательных приближения могут быть выражены как ai = A + B * hn и ai + 1 = A + B * hn / 2n. Теперь член погрешности может быть устранён: A = (ai + 1 * 2n - ai ) / 2n – 1
(3.2)
Конечно, ошибка определяется степенью h только при грубом приближении. Поэтому наше заключение также приблизительно, но всё равно, это намного лучшее приближение. Подобное усовершенствование может применяться ко всем последовательным парам приближений, с помощью функции elimerror n (a : b : rest) = (b * 2 ^ n - a) / (2 ^ n - 1) : elimerror n (b : rest)
которая устраняет погрешность из последовательности приближений и выдаёт другую последовательность, сходящуюся намного быстрее. Но одна проблема остаётся. Прежде, чем мы можем использовать elimerror — мы должны знать значение n. Его трудно предсказать, но можно измерить. Нетрудно показать, что следующая оценка правильна, но не будем приводить доказательства. n = round log2 (ai + 2 - ai) / (aii + 1 - ai) - 1
(3.3)
order a : b : c : rest = round log2 (a - c) / (b - c) - 1
Теперь можно определить общую функцию, улучшающую последовательность приближений:
Инв. № подл.
improve s = elimerror (order s) s
Лист
ФП 02005-02 01 ИзмЛист № докум.
15
Подп. Дата Копирова
Формат
ФП 02005-02 01
Производную функции f можно вычислить более эффективно, используя improve следующим образом: within eps (improve (differentiate h0 f x))
Отметим что improve подходит только для последовательностей приближений, которые вычислены с использованием параметра h, делимого на два при каждом шаге. Однако, если она применена к такой последовательности, её результат — такая же последовательность! Это означает, что последовательность приближений может быть улучшена больше чем однажды. Каждый раз погрешность устраняется, и последовательности сходятся всё быстрее и быстрее. Производную можно вычислить очень эффективно используя: within eps (improve (improve (improve (differentiate h0 f x))))
В терминах вычислительной математики это, вероятно, будет методом четвёртого порядка, и даёт точный результат очень быстро. Можно даже определить:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
super s = map second (iterate improve s) second (a : b : rest) = b
Здесь super использует (iterate improve), чтобы получить последовательность всё более улучшенных последовательностей приближений, и строит новую последовательность приближений, беря второе приближение каждой из улучшенных последовательностей (оказывается, что лучше всего брать второе — оно более точно чем первое и не требует дополнительных вычислений). Этот алгоритм действительно очень сложный — он использует всё лучший и лучший численный метод, по мере того как всё больше приближений вычислено. Производные можно очень эффективно вычислять программой: within eps (super (differentiate h0 f x))
Вероятно, это похоже на использование кувалды, чтобы расколоть орех, но смысл в том, что даже столь сложный алгоритм как super легко выражается путём модуляризации с использованием ленивых вычислений.
3.3. Численное интегрирование Последний пример, который мы обсудим в этом разделе — численное интегрирование. Задача может быть поставлена очень просто: для вещественной функции f одного вещественного аргумента, и двух конечных точек a и b, вычислить площадь под кривой описываемой f между конечными точками. Самый простой способ — предположить, что f-— почти прямая линия, тогда область была бы: easyintegrate f a b = (f a + f b) * (b - a) / 2
Лист
ФП 02005-02 01 ИзмЛист № докум.
16
Подп. Дата Копирова
Формат
ФП 02005-02 01
К сожалению, эта оценка, вероятно, будет очень неточна, если a и b не близки друг к другу. Лучшую оценку можно получить, разделив интервал (a, b) на два, вычислив площадь для каждой половины, и сложив результаты. Мы можем определять последовательность всё лучших и лучших приближений к значению интеграла, используя формулу для первого приближения, и затем, складывая приближения к интегралам на каждой половине вычислять другие. Эта последовательность вычисляется функцией: integrate f a b = easyintegrate f a b : map addpair (zip (integrate f a mid) (integrate f mid b)) where mid = (a + b) / 2 addpair (a, b) = a + b
Функция zip — ещё одна стандартная функция обработки списков. Она получает два списка и возвращает список пар, состоящих из соответствующих элементов этих списков. Таким образом, первая пара состоит из первого элемента первого списка и первого элемента второго, и так далее. Функция zip может быть определена следующим образом:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
zip (a:s) (b:t) = (a, b):(zip s t)
В integrate zip вычисляет список пар соответствующих приближений к интегралам на этих двух подынтервалах, и map addpair складывает элементы пары вместе, выдавая список приближений к интегралу. Эта версия интегрирует довольно неэффективно, потому что непрерывно перевычисляет значения f. Как уже сказано, easyintegrate вычисляет значения f в точках a и b, а рекурсивные обращения перевычисляют каждое из них. К тому же и (f mid) вычисляется при каждом рекурсивном обращении. Поэтому следует вторая версия, которая никогда не перевычисляет значение f: integrate f a b = integ f a b (f a) (f b) integ f a b fa fb = (f a + f b) * (b - a) / 2 : map addpair (zip (integ f a m fa fm) (integ f m b fm fb)) where m = (a + b) / 2 fm = f m
Функция integrate вычисляет бесконечный список лучших и лучших приближений к интегралу, также как differentiate, в предыдущем разделе. Можно поэтому написать программу, которая интегрирует до любой требуемой точности, как в: within eps (integrate f a b) relative eps (integrate f a b)
Этот алгоритм интегрирования страдает от того же самого недостатка, что и первый алгоритм дифференцирования — он сходится довольно медленно. И так же он может быть улучшен. Первое приближение в последовательности вычисленный (easyintegrate) использует только две точки, с расстоянием между ними b - a. Второе приближение использует середину отрезка, так что расстояние между соседними точками — (b - a) / 2. Третье приближение использует этот метод на каждой половине интервала, так что расстояние между соседними точками становится (b - a) / 4. Ясно, что расстояние между
Лист
ФП 02005-02 01 ИзмЛист № докум.
17
Подп. Дата Копирова
Формат
ФП 02005-02 01
соседними точками делится на два при каждом следующем приближении и наша последовательность — кандидат на усовершенствование, используя «улучшающую» функцию (improve) определённую в предыдущем разделе. Поэтому мы можем теперь создавать быстро сходящиеся последовательности приближений к интегралам, например: super (integrate sin 0 4) improve (integrate f 0 1) where f x = 1 / (1 + x * x)
Вторая последовательность — метод восьмого порядка для вычисления pi / 4. Второе приближение, которое требует только пяти вычислений f, выдаёт результат с точностью до пятого знака.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
В этом разделе мы запрограммировали несколько числовых алгоритмов. Благодаря ленивым вычислениям мы смогли разбить их на модули новыми способами, получая общеполезные функции типа within, relative, improve. Объединяя эти части различными способами, мы очень легко и просто запрограммировали несколько весьма хороших числовых алгоритмов.
Лист
ФП 02005-02 01 ИзмЛист № докум.
18
Подп. Дата Копирова
Формат
ФП 02005-02 01
4. ПРИМЕР ИЗ ИСКУССТВЕННОГО ИНТЕЛЛЕКТА Мы утверждали, что функциональные языки мощны, прежде всего, потому что они обеспечивают два новых типа связующих элементов: функции более высокого порядка и ленивые вычисления. Возьмём пример из «искусственного интеллекта» и покажем, что его очень просто можно запрограммировать, используя эти два вида типов связующих элементов. Пример, который мы выбираем — «альфа-бета эвристика», алгоритм для оценки позиции в которой находится игрок. Алгоритм работает, просматривая возможные ходы, чтобы видеть, как игра могла бы развиваться, но избегает анализа невыгодных вариантов. Пусть позиции в игре представлены объектами типа position. Это тип изменяется от игры к игре, и мы о нём ничего не предполагаем. Но должен существовать некоторый метод для того, чтобы узнать, какие ходы могут быть сделаны в данной позиции. Предположим, что имеется функция:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
moves :: Position -> [Position]
Эта функция получает игровую позицию как параметр и возвращает список всех позиций, которые могут быть достигнуты в один ход. Предполагается, что всегда можно узнать из позиции, чья очередь хода. В крестиках-ноликах это можно сделать, подсчитав крестики и нолики. В игре, подобной шахматам, информация должна быть явно включена в тип Position. Прежде всего надо сформировать дерево игры. Это дерево, в котором узлы помечены позициями, так что дочерние элементы узла помечены позициями, которые могут быть достигнуты в один ход из этого узла. То есть если узел помечен позицией p, то его дочерние элементы помечены позициями (moves p). Дерево может быть бесконечным. Игровые деревья подобны деревьям, которые мы обсуждали в разделе 1 — каждый узел имеет метку (т.е. позицию) и список подузлов. Поэтому мы можем использовать для их представления тот же самый тип данных. Дерево игры формируется повторными применениями moves. Начиная с корневой позиции, moves используются, чтобы генерировать метки для поддеревьев корня. Затем moves используются снова, чтобы генерировать поддеревья поддеревьев и так далее. Этот рекурсивный образец может быть выражен функцией высшего порядка: reptree f a = Node a (map (reptree f) (f a))
Используя эту функцию можно определить другую функцию, которая создает игровое дерево, начиная со специфической позиции: gametree p = reptree moves p
Лист
ФП 02005-02 01 ИзмЛист № докум.
19
Подп. Дата Копирова
Формат
ФП 02005-02 01
Функция высшего порядка, используемая здесь (reptree) аналогична функции iterate, создававшей бесконечные списки в предыдущем разделе. Алгоритм просматривает дерево игры, начиная с данной позиции, пытаясь определить, насколько благоприятно будет развиваться игра. Но чтобы сделать это, он должен иметь возможность делать грубую оценку значения позиции без просмотра вперёд. Эта «статическая оценка» должна использоваться на границах просмотра вперёд. Результат статической оценки является мерой обещания позиции с точки зрения компьютера (предполагается, что компьютер играет против человека). Чем больше результат, тем лучше позиция для компьютера. В самом простом случае такая функция возвращает +1 для позиций, где компьютер уже победил, –1 для позиций, где компьютер уже проиграл, и 0 в остальных случаях. В действительности, функция статической оценки измеряет различные вещи, которые делают позицию «хорошей», например, материальное преимущество и контроль центра в шахматах. Предположим, что мы имеем такую функцию:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
static: Position -> Number
Так как игровое дерево есть Treeof Position, оно может быть преобразовано в Treeof Number функцией (maptree static), которая статически вычисляет все позиции в дереве (их может быть бесконечно много). Здесь используется функция maptree, определенная в разделе 1. Как найти «истинные» значения позиций из такого дерева статических оценок? В частности какое значение должно быть приписано корневой позиции? Её статическое значение — только грубое предположение. Значение, приписанное узлу, должно определятся из значений его подузлов. Это может быть сделано в предположении, что каждый игрок делает лучшие ходы. Поскольку высокое значение означает хорошую позицию для компьютера, ясно что, он выберет ход, ведущий к подузлу с максимальным значением. Точно так же противник выберет ход к подузлу с минимальным значением. Значение узла вычисляются функцией maximise, если очередь компьютера и minimise, иначе: maximise (Node n sub) = maximum (map minimise sub) minimise (Node n sub) = minimum (map maximise sub)
Здесь maximum и minimum — функции на списках чисел, которые возвращают максимум и минимум списка соответственно. Эти определения не закончены, потому что они будут зацикливаться — нет базового случая. Мы должны определить значение узла без преемников, и мы определяем его как статическую оценку узла. Статическая оценка используется когда игрок уже выиграл, или на границах просмотра. Законченные определения maximise и minimise: maximise (Node n []) = n maximise (Node n sub) = maximum (map minimise sub)
Лист
ФП 02005-02 01 ИзмЛист № докум.
20
Подп. Дата Копирова
Формат
ФП 02005-02 01
minimise (Node n []) = n minimise (Node n sub) = minimum (map maximise sub)
Уже можно было бы записать функцию, которая возвращает значение позиции: evaluate = maximise . maptree static . gametree
Имеются две проблемы с этим определением. Прежде всего, оно не работает для бесконечных деревьев. Функция maximise продолжает рекурсивно вызываться, пока не находит узла без поддеревьев — конец дерева. Если нет никакого конца, то maximise не вернёт никакого результата. Вторая проблема связана с первой — даже конечные игровые деревья могут быть очень большими. Нереалистично пытаться оценить игровое дерево целиком — поиск должен быть ограничен следующими немногими ходами. Это можно сделать, обрезая дерево до установленной глубины: prune 0 (Node a x) = Node a [] prune n (Node a x) = Node a (map (prune (n - 1)) x)
Функция (prune n) берёт дерево и «вырезает» все узлы, расположенные далее, чем на n от корня. Если дерево обрезано, maximise будет использовать статическую оценку для узлов глубины n. Поэтому evaluate можно определить так:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
evaluate = maximise . maptree static . prune 5 . gametree
Что просматривает (скажем) на 5 шагов вперёд. Уже здесь мы использовали функции высшего порядка и ленивые вычисления. Функции высшего порядка reptree и maptree позволяют нам с лёгкостью создавать и управлять игровыми деревьями. Более важно то, что ленивые вычисления разрешает нам модуляризировать вычисления таким образом. Поскольку функция gametree выдаёт потенциально бесконечный результат, эта программа никогда не закончилась бы без ленивых вычислений. Вместо (prune 5 . gametree) мы были бы должны свернуть эти две функции вместе в одну такую, которая создавала бы только первые пять уровней дерева. Но даже дерево первых пяти уровней может быть слишком большим, чтобы разместиться в памяти. В программе которую мы написали, функция (maptree static . prune 5 . gametree) создаёт только те части дерева, которые требует maximise. Так как каждая часть может быть удалена (сборщиком мусора), как только maximise покончит с ней, дерево никогда не находится в памяти целиком. Только маленькая часть дерева хранится в один момент времени. Поэтому ленивая программа эффективна. Поскольку эта эффективность зависит от взаимодействия между maximise (последняя функция в цепочке) и gametree (первая), её можно получить без ленивых вычислений только сворачивая все функции цепочки вместе в одну большую. Это значительно уменьшает модульность, но именно это обычно и делается. Мы можем сделать усовершенствования этого несерьёзного алгоритма оценки. Для каждой его части
Лист
ФП 02005-02 01 ИзмЛист № докум.
21
Подп. Дата Копирова
Формат
ФП 02005-02 01
это относительно просто. Обычный программист должен изменить всю программу целиком, что намного сложнее. Пока мы описали только простой минимаксный алгоритм. Основа альфа-бета алгоритма — наблюдение, что часто можно вычислить значение maximise или minimise без просмотра целого дерева. Рассмотрим дерево: max / \ min min / \ / \ 1 2 0 ?
Нет необходимости знать значение (?), чтобы оценить дерево. Левый минимум равен 1, но правый минимум явно меньше или равен 0. Поэтому максимум из этих двух минимумов должен быть 1. Это наблюдение может быть обобщено и встроено в maximise и minimise. Первый шаг — выделить применение minimise к списку чисел, то есть мы разлагаем, maximise как:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
maximise = maximum . maximise'
(Функция minimise, разбивается подобным образом. Так как minimise и maximise полностью симметричны, мы обсудим maximise и предполагаем, что minimise обрабатывается так же). Разделённая таким образом maximise может использовать minimise' а не minimise, чтобы определить, для каких чисел minimise, будет искать минимум. Тогда можно отказаться от некоторых чисел не глядя на них. Благодаря ленивым вычислениям, если maximise не просматривает весь список чисел, некоторые из них не будет вычислены, с потенциальным сохранением времени вычислений. Несложно «вынести за скобки» maximum из определения maximise: maximise' (Node n []) maximise' (Node n l)
= = = = =
n : [] map minimise l map (minimum . minimise') l map minimum (map minimise' l) mapmin (map minimise' l) where mapmin = map minimum
Так как minimise' возвращает список чисел, минимум которых — результат minimise, то (map minimise' l) возвращает список списков чисел. Функция maximise' должна возвратить список минимумов этих списков. Однако, только максимум этого списка имеет значение. Мы определим новую версию mapmin, которая опускает минимумы тех списков, чей минимум не имеет значения: mapmin nums : rest = min nums : omit min nums rest
Лист
ФП 02005-02 01 ИзмЛист № докум.
22
Подп. Дата Копирова
Формат
ФП 02005-02 01
Функция omit получает «потенциальный максимум» — самый большой, из замеченных на данный момент минимумов — и опускает любые минимумы, которые меньше его. omit pot [] = [] omit pot (nums : rest) =
if minleq nums pot then omit pot rest else (minimum nums) : omit (minimum nums) rest
Функция minleq получает список чисел и потенциальный максимум, и возвращает истину если минимум списка чисел меньше или равен потенциальному максимуму. Чтобы сделать это, не требуется смотреть на весь список! Если имеется любой элемент в списке меньший или равный потенциальному максимуму, то это — минимум списка. Все элементы после этого несущественны, они обозначены знаком (?) в примере выше. Поэтому minleq может быть определен следующим образом: minleq [] pot = False minleq (num:rest) pot = num <= pot || minleq rest pot
Определив maximise' и minimise', можно просто записать новую evaluate:
Взам. инв. № Инв. № дубл.
Подп. и дата
evaluate = maximum . maximise' . maptree static . prune 8 . gametree
Благодаря ленивым вычислениям, если maximise' просматривает меньшую часть дерева то и вся программа выполняется более эффективно. То, что prune просматривает только часть бесконечного дерева, даёт возможность программе закончиться. Оптимизация maximise' хоть и довольно проста, но может оказать значительное воздействие на быстродействие, и позволяет просматривать намного дальше. Можно сделать и другие улучшения. Например, альфа-бета алгоритм работает лучше всего, если сначала рассматриваются лучшие ходы, так как, если найден очень хороший ход то, нет необходимости рассматривать худшие ходы, только для того, чтобы продемонстрировать, что противник имеет по крайней мере один хороший ответ на них. Можно было бы, поэтому, сортировать поддеревья в каждом узле, помещая в начале узлы с самыми высокими значениями оценки, при ходе компьютера, и с самыми низкими в противном случае. Это может быть сделано функциями: lowfirst (Node n sub) = Node n (sort (map highfirst sub ))) highfirst (Node n sub) = Node n (reverse (sort (map lowfirst sub ))
Подп. и дата
Где sort — универсальная функция сортировки. Теперь можно определить: evaluate = max . maximise' . highfirst . maptree static . prune 8 . gametree
Может оказаться достаточно рассматривать только три лучших хода компьютера или противника, чтобы ограничить поиск. Чтобы запрограммировать это, надо только заменить highfirst на (taketree 3 . highfirst), где
Инв. № подл.
taketree n = redtree (nodett n) (:) []
Лист
ФП 02005-02 01 ИзмЛист № докум.
23
Подп. Дата Копирова
Формат
ФП 02005-02 01
nodett n x sub = Node x (take n sub)
Функция taketree заменяет все узлы в дереве узлами содержащими не более n подузлов, используя функцию (take n), которая возвращает первые n элементов списка (или меньшее количество если список короче чем n). Ещё одно улучшение — усовершенствование отсечений. Программа просматривает на установленную глубину, даже если позиция очень динамическая. Например в шахматах она может прекратить просмотр в позиции, в которой ферзь находится под боем. Обычно определяются некоторые «динамические» позиции в которых нельзя прерывать просмотр. Предположив что функция dynamic распознаёт такие позиции, мы должны добавить только одно уравнение для prune: prune 0 (Node pos sub) | dynamic pos = Node pos (map (prune 0) sub)
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Легко вносить такие изменения если программа столь же модульная, как эта. Как мы отмечали выше, эффективность программы кардинально зависит от взаимодействия между maximise и gametree (то есть между первой и последней функциями в цепочке), без ленивых вычислений она может быть написана только как монолитная программа, которую трудно написать, очень трудно изменять и чрезвычайно трудно понять.
Лист
ФП 02005-02 01 ИзмЛист № докум.
24
Подп. Дата Копирова
Формат
ФП 02005-02 01
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
5. ЗАКЛЮЧЕНИЕ В этой статье утверждается, что модульность — ключ к успешному программированию. Языки, которые стремятся улучшить производительность, должны поддерживать модульное программирование. Но новые правила видимости и механизмы раздельной трансляции недостаточны. Модульность — нечто большее, чем модули. Наша способность расчленять проблему на части зависит непосредственно от нашей способности связать решения. Чтобы поддерживать модульное программирование, язык должен обеспечить хорошие типы связующих элементов. Функциональные языки программирования обеспечивают два новых вида связующих элементов — функции высшего порядка и ленивые вычисления. При использовании этих связующих элементов программы могут быть модуляризованы новыми и захватывающими способами, и мы дали много примеров этому. Маленькие и очень общие модули могут широко и многократно использоваться, облегчая последующее программирование. Это объясняет, почему функциональные программы намного меньше, и почему их проще писать, чем обычные. Если какая то часть программы беспорядочна или сложна, программист должен попытаться разбить её на модули и обобщить части. Он должен попытаться использовать функции высшего порядка и ленивые вычисления как инструментальные средства для выполнения этой задачи. Конечно, мы не первые, кто указывает на мощность и элегантность функций высшего порядка и ленивых вычислений. Например, Тёрнер показывает, они могут использоваться с большой выгодой в программе для порождения химических соединений [Tur81]. Абельсон и Сусман подчёркивают, что потоки (ленивые списки) — мощный инструмент для структурирования программ [AS86]. Хендерсон использовал потоки, чтобы структурировать функциональные операционные системы [P.H82]. Основной вклад этой статьи — утверждение, что лучшая организация модульности — ключ к мощности функциональных языков. Статья имеет отношение и к существующей полемике по поводу ленивых вычислений. Одни полагают, что функциональные языки должны быть ленивы, другие — что не должны. Некоторые ищут компромисс и предлагают только ленивые списки, со специальным синтаксисом для их построения (как, например, в Scheme [AS86] ). Эта статья даёт дальнейшие основания утверждать, что ленивые вычисления слишком важны, чтобы быть низведёнными к технологиям второго сорта. Это, возможно, наиболее мощный тип связующих элементов, которым обладают функциональные программисты. Нельзя затруднять доступ к такому жизненно важному инструменту.
Лист
ФП 02005-02 01 ИзмЛист № докум.
25
Подп. Дата Копирова
Формат
ФП 02005-02 01
6. ЛИТЕРАТУРА 1) [Wir82] N. Wirth. Programming in Modula-II. Springer-Verlag, 1982. 2) [MTH90] R. Milner, M. Tofte, and R. Harper. The Definition of Standard ML.MIT Press, 1990. 3) [oD80] United States Department of Defense. The Programming Language Ada Reference Manual. Springer-Verlag, 1980. 4) [P.H82] P.Henderson. Purely Functional Operating Systems. 1982. 5) [Tur81] D. A. Turner. The Semantic Elegance of Applicative Languages. In Proceedings 1981 Conference on Functional Languages and Computer Architecture, Wentworth-by-the-Sea, Portsmouth, New Hampshire, 1981.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
6) [AS86] H. Abelson and G.J. Sussman. Structure and Interpretation of Computer Programs. MIT Press, Boston, 1986.
Лист
ФП 02005-02 01 ИзмЛист № докум.
26
Подп. Дата Копирова
Формат
Лист регистрации изменений Номера листов (страниц) Изм. изменённых
заменённых
новых
аннулированных
Всего листов (страниц) в докум.
№ документа
Входящий № сопроводительного докум. и дата
Подп.
Дата