Функциональное программирование. Конспект лекций
.pdf
(*)a Zero = Zero
(*)a (Succ b) = a + (a * b)
И сложение, и функция умножения получились рекурсивными. Они следуют по одному сценарию: сначала определяется база рекурсии – тот случай, при котором заканчивается вычисление функции, и затем определяется цепочка рекурсивных вызовов.
Другим примером рекурсивного определения типа данных является двоичное дерево. Двоичным деревом называют структуру данных, содержащую элементы, связанные друг с другом таким образом, что каждый элемент (узел дерева) связан с единственным другим элементом (его непосредственным предком), причем этот предок является предком для не более чем двух других элементов,
называемых его непосредственными потомками. У корня дерева нет непосредственного предка. Узлы, не имеющие потомков, называются листьями дерева. Иногда рассматривают также и пустое дерево, не содержащее узлов вообще.
Двоичное дерево можно определить рекурсивно следующим образом: двоичным деревом называется либо пустое дерево, либо совокупность из одного узла (корня дерева) и двух других деревьев, называемых левым и правым поддеревьями этого узла. Задавая два конструктора, первый из которых будет порождать пустое дерево (конструктор Null), а второй (Tree) будет иметь в качестве параметров узел и два его поддерева, получим
data Tree a = Null | Tree a (Tree a) (Tree a).
Здесь идентификатор Tree используется в двух разных смыслах: как идентификатор типа данных и как идентификатор конструктора, причем в конструкции Tree a (Tree a) (Tree a) первое вхождение идентификатора Tree – это определение конструктора, а два других – рекурсивные ссылки на идентификатор определяемого типа данных.
2.2. Значения
Значения представляют собой выражения–синонимы, построенные на основе встроенных или определенных пользователем типов. Например, если определен тип
– 21 –
data Bool = True | False,
то можно определить простые выражения-синонимы-константы:
true :: Bool true = True false :: Bool false = False
или определить выражения-функции:
not :: Bool -> Bool not True = False not False = True
and :: Bool -> Bool -> Bool
and False _ = False
and True x = x
or :: Bool -> Bool -> Bool or True _ = True
or False x = x
Здесь определяется новое имя not с типом Bool -> Bool. Оно определяется двумя уравнениями. Слева от знака равно левая часть уравнения, а справа – правая. В первом уравнении указано, что сочетание (not True) означает False, а сочетание (not False) означает True. Следует отметить, что ничего не вычисляется, а даются новые имена константам True и False. Только в этом случае имена составные. Если вычислителю Haskell нужно узнать, что кроется за составным именем not False, он последовательно проанализирует уравнения сверху вниз до тех пор, пока левая часть уравнения не совпадет со значением not False.
В выражениях and False _ = False и or True _ = True символ под-
черкивания «_» означает, что если задан первый параметр в уравнении, то второй не важен. Используя приведенные выражения можно построить более сложные выражения-функции:
xor :: Bool -> Bool -> Bool
xor a b = or (and (not a) b) (and a (not b))
– 22 –
или
ifThenElse :: Bool -> a -> a -> a ifThenElse True t _ = t ifThenElse False _ e = e
Функция ifThenElse первым аргументом принимает значение типа Bool, а вторым и третьим – альтернативы некоторого типа a. Если первый аргумент – True, то возвращается второй аргумент, а если – False, то третий.
В ряде случаев у функции может быть контекст, который говорит о том, что аргумент принадлежит некоторому классу, например
f :: C a => a -> b.
Тогда значение, которое подставляется в функцию, должно быть экземпляром класса C.
2.2.1. Локальные выражения
Использование локальных выражений позволяет ограничить область видимости функции или переменной внутри определения другой функции. В ряде случаев это позволяет сделать код более компактным и наглядным.
Рассмотрим пример функции вычисления корней квадратного уравнения:
sqroot::Double->Double->Double->(String, Double, Double)
sqroot a b c | d>=0 = ("real", r1+r2, r1-r2)
| otherwise = ("complex", r1, r2) where d=b*b - 4*a*c
r1=(-b)/(2*a) r2=sqrt(abs(d))/(2*a)
Аргументами функции являются коэффициенты квадратного уравнения. Функция возвращает кортеж вида (String, Double, Double), в котором первым элементом указывается тип корней («real» или «coplex»), а далее сами корни. Здесь переменные d, r1 и
– 23 –
r2 являются локальными, т.е. могут быть использованы только внутри функции sqroot. Здесь очень важно соблюдать отступы и выравнивание, т.е. использовать двумерный синтаксис, о котором будет сказано ниже.
Другим способом использования локальных переменных являются так называемые let–in выражения. Например, формула вычисления площади треугольника может быть записана как
S = p(p − a)(p − b)(p − c),
где a, b и c – длины сторон треугольника, а p – полупериметр. Функция вычисления площади треугольника с помощью let-in
выражения выглядит как
square a b c = let p = (a + b + c) / 2
in sqrt (p * (p - a) * (p - b) * (p
- c))
Слова let и in – ключевые. let-выражения являются обычными выражениями и не привязаны к определенному месту, как whereвыражения. Они могут участвовать в любой части обычного выражения, например:
square a b c = let p = (a + b + c) / 2
in sqrt ((let pa = p - a in p * pa)
*
(let pb = p - b pc = p - c
in pb * pc))
Здесь let-выражение верхнего уровня let p = (a + b + c) / 2 используется для вычисления вложенных let-выражений let pa = p - a in p * pa и let pb = p - b.
Разработку программы можно представить как определение базовых элементов и способов составления новых. Далее происходит вычисление комбинации из этих элементов, причем вычисление – это замена синонимов на комбинации простейших элементов.
– 24 –
2.3. Классы типов
В языке Haskell у каждой функции есть тип, но бывают функции, которые могут быть определены на аргументах разных типов. Например, функция сравнения на равенство, говорящая о том, что два значения одного типа a равны, имеет тип
(= =)::a -> a -> Bool.
Здесь тип a является любым типом, для которого сравнение на равенство имеет смысл.
Классы типов (type class) позволяют определять функции с одинаковым именем для разных типов. У классов типов есть имена. Например, класс сравнений на равенство называется Eq. Определение класса Eq выглядит следующим образом:
class Eq a |
where |
|
(==) |
:: a |
-> a -> Bool |
(/=) |
:: a |
-> a -> Bool |
За ключевым словом class следует имя класса (с заглавной буквы), тип-параметр и еще одно ключевое слово where. Далее с отступами пишутся имена определенных в классе методов.
Класс типов также может содержать контекст. Он указывается между словом class и именем класса. Например:
class IsPerson a => HasName a where name :: a -> String
Это значит, что можно сделать экземпляр класса HasName только для тех типов, которые содержатся в IsPerson. Говорят, что класс HasName содержится в IsPerson. В этом случае класс из контекста IsPerson называют суперклассом для данного класса
HasName.
2.4. Экземпляры классов типов
В экземплярах (instance) классов типов реализуются методы класса типов. Определение экземпляра пишется так же, как и определение класса типа, но вместо class пишется слово instance, а так-
– 25 –
же указывается конкретный тип. Например, экземпляры класса Eq для Bool будут выглядеть так:
instance Eq Bool where
(= =) True True = True (= =) False False = True (= =) _ _ = False
(/=) a b = not (a = = b)
Для некоторых стандартных классов экземпляры классов типов могут быть выведены автоматически. Это делается с помощью директивы deriving. Она пишется сразу после объявления типа. Например, можно определить тип и экземпляры для классов Show и Eq для типа T:
data T = A | B | C deriving (Show, Eq)
Понятие класса типов и их экземпляры более подробно будут рассмотрены в лекции, посвященной монадам и функторам.
2.5. Двумерный синтаксис в Haskell
В Haskell нет разделителей строк и дополнительных скобок, которые бы указывали границы определения классов или функций. Компилятор Haskell ориентируется по переносам строки и отступам. Так, если в описании класса Eq записано:
class Eq a where
(= =) :: a -> a -> a (/=) :: a -> a -> a
то по отступам за первой строкой определения компилятор понимает, что класс содержит два метода – (= =) и (/=). Если бы было записано как
class Eq a where
(= =) :: a -> a -> a (/=) :: a -> a -> a
– 26 –
то смысл был бы совсем другим. Определяется класс Eq с одним методом (= =) и указывается тип некоторого значения (/=).
При написании программ следует придерживаться следую-
щего правила: логически связанные конструкции должны располагаться на одном уровне и выравниваться с помощью отступов. Чем правее находится определение, тем глубже оно вложено в какуюнибудь специальную конструкцию.
Часто отступы набираются с помощью табуляции. Но лучше пользоваться пробелами или настроить текстовый редактор так, чтобы он автоматически заменял табуляцию на пробелы. Дело в том, что при добавлении новой инструкции в середину блока редактор, как правило, выравнивает инструкции с помощью табуляции, приводя к тому, что Haskell считает эту инструкцию уже не находящейся в блоке, и выдает сообщение об ошибке! Признаком хорошего стиля в Haskell считается полный отказ от табуляции.
– 27 –
Лекция 3. Рекурсия
Описываются понятие и реализация рекурсии на языке Haskell. Приводятся примеры рекурсивного вызова и понятие хвостовой рекурсии.
Ключевые слова: рекурсия; хвостовая рекурсия; условия окончания рекурсии.
Введение. Рекурсия в Haskell является основным механизмом разработки программ. Определение функции может быть рекурсивным, т.е. в правой части уравнения может быть вызов определяемой функции. В этом случае в процессе преобразования выражения, содержащего вызов рекурсивной функции, может получаться выражение, также содержащее вызов той же самой функции. Для того чтобы процесс вычисления мог закончиться, необходимо использовать условные выражения, которые приводят к выбору одной из двух альтернатив при вычислении сложных выражений.
3.1. Рекурсивное вычисление факториала
Рассмотрим несколько определений простой рекурсивной функции для вычисления факториала:
factorial :: Integer -> Integer
factorial n = if n == 0 then 1 else n * factorial (n-1)
или
fact1 :: Integer -> Integer
fact1 n | |
n |
= |
= |
0 |
= 1 |
| |
n |
> |
0 |
|
= n * fact1 (n-1) |
или
fact2 :: Integer -> Integer fact2 0 = 1
– 28 –
fact2 1 = 1
fact2 n = n * fact2 (n-1)
В первом определении используется условное выражение языка
Haskell:
if <условие> then <выражение-"то"> else <выражение-"иначе">
При вычислении условного выражения, прежде всего, вычисляется <условие>. Тип вычисленного выражения-условия должен быть логическим (Bool). Если вычисленное значение оказывается истинным, то вместо всего условного выражения подставляется <выражение-"то">, а <выражение- "иначе"> отбрасывается. Если же <условие> оказалось ложным, то отбрасывается <выражение- "то">, а вместо всего условного выражения остается только часть, определенная <выражением-"иначе">.
Рекурсия присутствует в вызове функции factorial со значением аргумента уменьшенным на единицу, т.е. factorial (n-1). Для вычисления выражения factorial (n-1) необходимо найти синоним- выражение-функцию для его преобразования. Но такое выражение-
функция только одно – функция factorial :: Integer -> Integer. Сопо-
ставление с этой функцией приводит к рекурсивному вызову factorial (n-2). Данный процесс выполняется до тех пор, пока значение аргумента станет равным нулю. Условие станет истинным и значение функции будет равно 1. К этому моменту будет сформировано выражение вида n*(n-1)*(n-2)*…*2*1, которое представляет композицию операций умножения. Выполнение (свертка) этого выражения приведет к вычислению факториала n!.
Реализация вычисления факториала с помощью функции fact1 основано на использовании «охранных» выражений с помощью символа «|». Этот символ разделяет альтернативы в условных выражениях. Сначала пишется «|», затем выражение-предикат, которое возвращает значение типа Bool, затем равно и после равно – возвращаемое значение. Альтернативы обходятся сверху вниз до тех пор, пока в одной из альтернатив предикат не вернет значение True. В качестве последней альтернативы можно указать констан- ту-синоним otherwise == True, которая будет всегда выполняться, если предыдущие альтернативы оказались ложными. Здесь рекурсия присутствует в альтернативе (n > 0) при вызове функции fact1 со значением аргумента уменьшенным на единицу, т.е. fact1 (n-1).
– 29 –
Для вычисления выражения fact1 (n-1) необходимо найти синоним- выражение-функцию для его преобразования. Но такое выражениефункция только одно – функция fact1 :: Integer -> Integer. Сопоставление с этой функцией приводит к рекурсивному вызову fact1 (n-2) (если альтернатива (n > 0) истинна). Данный процесс выполняется до тех пор, пока значение аргумента станет равным нулю и истинной будет альтернатива (n = = 0). Функция вернет 1 и завершится формирование выражения вида n*(n-1)*(n-2)*…*2*1. Выполнение (свертка) этого выражения приведет к вычислению факториала n!.
Третий вариант вычисления факториала представлен с помощью определения трех функций fact2 :: Integer -> Integer для разных значений аргументов. Рекурсия представлена при реализации функции fact2 n = n * fact2 (n-1). При n=3 вычисление выражения fact2 3 будет происходить следующим образом:
1.В результате просмотра определений функций fact2 :: Integer -> Integer сверху вниз будет найдено совпадение с функцией fact2 n = n * fact2 (n-1) при n=3.
2.Выполняется редукция выражения fact2 3, т.е. замена его на
3 * fact2 2.
3.Для вычисления fact2 2 ядро Haskell («вычислитель») будет просматривать определения функций fact2 :: Integer -> Integer, и будет найдено совпадение с функцией fact2 n = n * fact2 (n-1) при n=2.
4.Выполняется редукция выражения fact2 2, т.е. замена его на
2 * fact2 1.
5.Для fact2 1 будет найден синоним fact2 1 = 1.
6.Будет получена композиция операций умножения 3*2*1 = 3!. Как видно, сначала происходит рекурсивная развертка выраже-
ния для вычисления факториала, и потом, когда значение аргумента рекурсивно вызываемой функции становится равным 0 или 1, происходит свертка простого арифметического выражение вида n*(n-1)*(n-2)*…*2*1. При больших значениях n это приводит к значительному времени вычисления факториала, так как сначала происходит построение выражения для вычисления, а потом собственно вычисление (свертка).
Для уменьшения времени подобных вычислений используется так называемая «хвостовая» рекурсия, смысл которой состоит в том, чтобы при развертывании рекурсии одновременно и вычис-
– 30 –
