ДУШКИН Роман Викторович
[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) 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