Миран Липовача - Изучай Haskell во имя добра!
Например, в школе есть шкафчики для того, чтобы ученикам было куда клеить постеры Guns’n’Roses. Каждый шкафчик открывается кодовой комбинацией. Если школьнику понадобился шкафчик, он говорит администратору, шкафчик под каким номером ему нравится, и администратор выдаёт ему код. Если этот шкафчик уже кем-либо используется, администратор не сообщает код – они вместе с учеником должны будут выбрать другой вариант. Будем использовать модуль Data.Map для того, чтобы хранить информацию о шкафчиках. Это будет отображение из номера шкафчика в пару, где первый компонент указывает, используется шкафчик или нет, а второй компонент – код шкафчика.
import qualified Data.Map as Map
data LockerState = Taken | Free deriving (Show, Eq)
type Code = String
type LockerMap = Map.Map Int (LockerState, Code)
Довольно просто. Мы объявляем новый тип данных для хранения информации о том, был шкафчик занят или нет. Также мы создаём синоним для кода шкафчика и для типа, который отображает целые числа в пары из статуса шкафчика и кода. Теперь создадим функцию для поиска кода по номеру. Мы будем использовать тип Either String Code для представления результата, так как поиск может не удаться по двум причинам – шкафчик уже занят, в этом случае нельзя сообщать код, или номер шкафчика не найден вообще. Если поиск не удался, возвращаем значение типа String с пояснениями.
lockerLookup :: Int –> LockerMap –> Either String Code
lockerLookup lockerNumber map =
case Map.lookup lockerNumber map of
Nothing –> Left $ "Шкафчик № " ++ show lockerNumber ++
" не существует!"
Just (state, code) –>
if state /= Taken
then Right code
else Left $ "Шкафчик № " ++ show lockerNumber ++ " уже занят!"
Мы делаем обычный поиск по отображению. Если мы получили значение Nothing, то вернём значение типа Left String, говорящее, что такой номер не существует. Если мы нашли номер, делаем дополнительную проверку, занят ли шкафчик. Если он занят, возвращаем значение Left, говорящее, что шкафчик занят. Если он не занят, возвращаем значение типа Right Code, в котором даём студенту код шкафчика. На самом деле это Right String, но мы создали синоним типа, чтобы сделать наши объявления более понятными. Вот пример отображения:
lockers :: LockerMap lockers = Map.fromList
[(100,(Taken,"ZD39I"))
,(101,(Free,"JAH3I"))
,(103,(Free,"IQSA9"))
,(105,(Free,"QOTSA"))
,(109,(Taken,"893JJ"))
,(110,(Taken,"99292"))
]
Давайте попытаемся узнать несколько кодов.
ghci> lockerLookup 101 lockers
Right "JAH3I"
ghci> lockerLookup 100 lockers
Left "Шкафчик № 100 уже занят!"
ghci> lockerLookup 102 lockers
Left "Шкафчик № 102 не существует!"
ghci> lockerLookup 110 lockers
Left "Шкафчик № 110 уже занят!"
ghci> lockerLookup 105 lockers
Right "QOTSA"
Мы могли бы использовать тип Maybe для представления результата, но тогда лишились бы возможности узнать, почему нельзя получить код. А в нашей функции причина ошибки выводится из результирующего типа.
Рекурсивные структуры данных
Как мы уже видели, конструкторы алгебраических типов данных могут иметь несколько полей (или не иметь вовсе), и у каждого поля должен быть конкретный тип. Принимая это во внимание, мы можем создать тип, конструктор которого имеет поля того же самого типа! Таким образом мы можем создавать рекурсивные типы данных, где одно значение некоторого типа содержит другие значения этого типа, а они, в свою очередь, содержат ещё значения того же типа, и т. д.
Посмотрите на этот список: [5]. Это упрощённая запись выражения 5:[]. С левой стороны от оператора : ставится значение, с правой стороны – список (в нашем случае пустой). Как насчёт списка [4,5]? Его можно переписать так: 4:(5:[]). Смотря на первый оператор :, мы видим, что слева от него – всё так же значение, а справа – список (5:[]). То же можно сказать и в отношении списка 3:(4:(5:6:[])); это выражение можно переписать и как 3:4:5:6:[] (поскольку оператор : правоассоциативен), и как [3,4,5,6].
Мы можем сказать, что список может быть пустым или это может быть элемент, присоединённый с помощью оператора : к другому списку (который в свою очередь может быть пустым или нет).
Ну что ж, давайте используем алгебраические типы данных, чтобы создать наш собственный список.
data List a = Empty | Cons a (List a) deriving (Show, Read, Eq, Ord)
Это можно прочитать почти как наше определение списка в одном из предыдущих разделов. Это либо пустой список, либо комбинация некоторого значения («головы») и собственно списка («хвоста»). Если такая формулировка трудна для понимания, то с использованием синтаксиса записей она будет восприниматься легче.
data List a = Empty | Cons { listHead :: a, listTail :: List a}
deriving (Show, Read, Eq, Ord)
Конструктор Cons может вызвать недоумение. Идентификатор Cons – всего лишь альтернативное обозначение :. Как вы видите, в списках оператор : – это просто конструктор, который принимает значение и список и возвращает список. Мы можем использовать и наш новый тип для задания списка! Другими словами, он имеет два поля: первое типа a и второе типа [a].
ghci> Empty
Empty
ghci> 5 `Cons` Empty
Cons 5 Empty
ghci> 4 `Cons` (5 `Cons` Empty)
Cons 4 (Cons 5 Empty)
ghci> 3 `Cons` (4 `Cons` (5 `Cons` Empty))
Cons 3 (Cons 4 (Cons 5 Empty))
Мы вызываем конструктор Cons как инфиксный оператор, чтобы наглядно показать, что мы используем его вместо оператора :. Конструктор Empty играет роль пустого списка [], и выражение 4 `Cons` (5 `Cons` Empty) подобно выражению 4:(5:[]).
Улучшение нашего списка
Мы можем определить функцию как инфиксную по умолчанию, если её имя состоит только из специальных символов. То же самое можно сделать и с конструкторами, поскольку это просто функции, возвращающие тип данных. Смотрите:
infixr 5 :–:
data List a = Empty | a :–: (List a) deriving (Show, Read, Eq, Ord)
Первое: мы использовали новую синтаксическую конструкцию, декларацию ассоциативности функции. Если мы определяем функции как операторы, то можем присвоить им значение ассоциативности, но не обязаны этого делать. Ассоциативность показывает, какова приоритетность оператора и является ли он лево- или правоассоциативным. Например, ассоциативность умножения – infixl 7 *, ассоциативность сложения – infixl 6. Это значит, что оба оператора левоассоциативны, выражение 4 * 3 * 2 означает ((4 * 3) * 2), умножение имеет более высокий приоритет, чем сложение, поэтому выражение 5 * 4 + 3 означает (5 * 4) + 3.
Следовательно, ничто не мешает записать a :–: (List a) вместо Cons a (List a). Теперь мы можем представлять списки нашего нового спискового типа таким образом:
ghci> 3 :-: 4 :-: 5 :-: Empty
3 :-: (4 :-: (5 :-: Empty))
ghci> let a = 3 :-: 4 :-: 5 :-: Empty
ghci> 100 :-: a
100 :-: (3 :-: (4 :-: (5 :-: Empty))
Напишем функцию для сложения двух списков. Вот как оператор ++ определён для обычных списков:
infixr 5 ++
(++) :: [a] –> [a] –> [a]
[] ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)
Давайте просто передерём это объявление для нашего списка! Назовём нашу функцию ^++:
infixr 5 ++
(^++) :: List a –> List a –> List a
Empty ^++ ys = ys
(x :–: xs) ++ ys = x :–: (xs ++ ys)
И посмотрим, как это работает…
ghci> let a = 3 :-: 4 :-: 5 :-: Empty
ghci> let b = 6 :-: 7 :-: Empty
ghci> a ++ b
3 :-: (4 :-: (5 :-: (6 :-: (7 :-: Empty))))
Очень хорошо. Если бы мы хотели, мы могли бы реализовать все функции для работы со списками и для нашего спискового типа.
Обратите внимание, как мы выполняли сопоставление с образцом по (x :–: xs). Это работает, потому что на самом деле данная операция сопоставляет конструкторы. Мы можем сопоставлять по конструктору :–: потому, что это конструктор для нашего собственного спискового типа, так же как можем сопоставлять и по конструктору :, поскольку это конструктор встроенного спискового типа. Так как сопоставление производится только по конструкторам, можно искать соответствие по образцам, подобным (x :–: xs), или константам, таким как 8 или 'a', поскольку на самом деле они являются конструкторами для числового и символьного типов[10].
Вырастим-ка дерево
Теперь мы собираемся реализовать бинарное поисковое дерево. Если вам не знакомы поисковые деревья из языков наподобие С, вот что они представляют собой: элемент указывает на два других элемента, один из которых правый, другой – левый. Элемент слева – меньше, чем текущий, элемент справа – больше. Каждый из этих двух элементов также может ссылаться на два других элемента (или на один, или не ссылаться вообще). Получается, что каждый элемент может иметь до двух поддеревьев. Бинарные поисковые деревья удобны тем, что мы знаем, что все элементы в левом поддереве элемента со значением, скажем, пять, будут меньше пяти. Элементы в правом поддереве будут больше пяти. Таким образом, если нам надо найти 8 в нашем дереве, мы начнём с пятёрки, и так как 8 больше 5, будем проверять правое поддерево. Теперь проверим узел со значением 7, и так как 8 больше 7, снова выберем правое поддерево. В результате элемент найдётся всего за три операции сравнения! Если мы бы искали в обычном списке (или в сильно разбалансированном дереве), потребовалось бы до семи сравнений вместо трёх для поиска того же элемента.