Haskell. Типы данных, паттерн матчинг и функции

Файлы имеют расширение .hs или .lhs (для Literate Haskell, с обратным способом записи комментариев и кода). Чтобы загрузить их в интерпретатор, можно либо вызвать его ghci file.hs, либо уже при работе с ним использовать команды :cd и :l.

gchi> :cd c:/some/haskell
ghci> :l file.hs
[1 of 1] Compiling Main ( file.hs, interpreted )
Ok, modules loaded: Main.
ghci>

Наверняка есть удобное IDE, автоматом делающее релоад при изменениях, но я пользуюсь просто редактором с подсветкой.

Типы данных

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

data Color = Red | Green | Blue

03 Конструкторы могут содержать параметры:

import Data.Word (Word8) -- импортируем Word8 из модуля Data.Word
data Color = Red | Green | Blue | Custom Word8 Word8 Word8

04 Кроме того, сам тип может быть параметризован используемым типом. Например, список:

data List a = Null | Cons a (List a)

05 Т. е. список элементов a либо пуст, либо состоит из (головы) a и (хвоста) List a. В описании конструктора можно использовать такой сахар:

data List a = Null |  Cons { listHead :: a, listTail :: List a }

06 Что автоматически определяет две функции:

listHead :: List a -> a
listTail :: List a -> List a

Которые элементарно реализуются.

Определение функций и паттерн матчинг

07 Определим полезные функции над нашим списком:

length Null = 0
length  (Cons _ xs) = 1 + length xs

08 Здесь я использовал сопоставление с образцом. Параметры функции последовательно (порядок определения важен) сравниваются с образцами (Null и Cons _ xs) и выбирается подходящий вариант. _ означает любое значение, но нам неважное. Т. е. Cons _ xs проходит для любого непустого списка. Образец может быть сколь угодно сложным:

someFunc (Cons 5 (Cons _ (Cons 3 (Cons _ Null)))) = True
someFunc _ = False

09 Но в нём можно использовать только конструкторы (и встроенные литералы). Т. е. в данном примере:

x = 4
wrong x = True
wrong _ = False

10 Первый вариант всегда сработает, так как x — это свободное имя, а не то значение, которое определено, как 4. Если одновременно с «разобранным» по частям образцом нам нужен и сам параметр функции (чтобы не собирать всё обратно, если вдруг нам это понадобится), то можно записать так:

someFunc v@(Cons 5 (Cons _ (Cons 3 (Cons _ Null)))) = -- v — параметр функции, сопоставленный с образцом

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

someFunc2 n = case n of
  Null -> "Null"
  Cons _ Null -> "One"
  Cons _ (Cons x _)
     | x == 0 -> "0"
     | x == 1 -> "1"
     | otherwise -> "otherwise" -- otherwise определена как True

12 Последние три строки в примере выше — это так называемые pattern guards. Когда значение подпадает под последний образец (в данном случае, вообще, он не обязан быть последним и pattern guards можно написать для каждого образца), то далее выбирается тот из вариантов, который даёт True. Тот же механизм работает и для функций:

someFunc3 (x:xs)
   | isSomePredicate x = xs
   | x == 0 = []
   | otherwise = (x:xs)

13 Кроме того, есть дополнительная нестандартная возможность. Вместо того чтобы записывать выражение типа Bool, можно записать некий образец и проверить любое выражение на совпадение с ним, пример:

someFunc4 (x:xs)
   | (2:ys) <- filter even xs = ys -- подсписок чётных начинается с 2 и непуст
   | (4:y:[]) <- xs = [y] -- xs состоит из 2-х элементов, первый — 4
   | otherwise = (x:xs)

14 Если образцы не способы покрыть все возможные значения, а оно таки там появится, то вылетит исключение. В частности, listHead (и стандартный head тоже) не способен обработать пустой список:

ghci> listHead Null
*** Exception: No match in record selector Main.listHead
ghci> head []
*** Exception: Prelude.head: empty list

15 Второй вариант даёт побольше информации, потому что на самом деле head для пустого списка определён, но он кидает исключение. Для таких случаев можно использовать стандартную функцию error:

listHead Null = error "listHead: empty list"
listHead (Cons x _) = x
ghci> listHead Null
*** Exception: listHead: empty list

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

listMap f Null = Null
listMap f (Cons x xs) = Cons (f x) (listMap f xs)

listFilter p Null = Null
listFilter p (Cons x xs)
   | p x = Cons x (listFilter p xs)
   | otherwise = listFilter p xs

listFoldr f v Null = v
listFoldr f v (Cons x xs) = f x $ listFoldr f v xs

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

foo (bar 3 (baz 56 "x"))

можно писать

foo $ bar 3 $ baz 56 "x"

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

Null @++ right = right
(@++) left Null = left
(Cons l ls) @++ right = Cons l (ls @++ right)

19 Дополнительно можно назначить оператору приоритет и левую или правую ассоциативность. С помощью ключевых слов infixl и infixr соответственно. Чтобы узнать приоритет оператора, в интерпретаторе можно воспользоваться командой :i

ghci> :i (++)
(++) :: [a] -> [a] -> [a] -- Defined in GHC.Base
infixr 5 ++

20 5 — это приоритет от 1 до 9, чем он больше, тем приоритет выше. Так как наш оператор аналогичен (++), то и приоритет ему поставим такой же.

infixr 5 @++

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

lst `atIndex` n = lst !! n

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

toList Null = []
toList (Cons x xs) = x:(toList xs)
fromList [] = Null
fromList (x:xs) = Cons x (fromList xs)

23 Напоследок напишем функцию, которая преобразует наш список в строку так же, как это работает и для обычного списка. Для этого сначала напишем функцию, которая вставляет дополнительный элемент между элементами списка, т. е. из [1,2,3] делает [1,5,2,5,3] (если вставляемый элемент 5):

listIntersperse with Null = Null
listIntersperse with (Cons x xs) = Cons x $ listFoldr (\x -> Cons with . Cons x) Null xs

24 Здесь в качестве функции свёртки используется лямбда. Лямбда — это анонимная функция, записывается как
\arg1 arg2 argN -> expr. В ней так же можно использовать сопоставление с образцом, но только с одним, т. е. записать несколько вариантов для нескольких образцов не получится, но если нужно, всегда можно воспользоваться case ... of.

25 Рассмотрим подробнее записанную здесь лямбду \x -> Cons with.Cons x, она принимает некое значение, а возвращает функцию, которая прицепляет к списку сам этот элемент, а затем элемент with, в результате мы получаем список Cons with (Cons x ...).

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

listShow lst = "[" ++ (listFoldr (++) "" $ listIntersperse "," $ listMap show lst) ++ "]"

listMap show lst

— Преобразует все элементы списка в строки;

27 listInterpserse ","

— Между элементами вставляет запятую;

28 listFoldr (++) ""

— Соединяет все строки в одну и с краёв мы добавляем скобки. Проверяем:

29 ghci> show [1,2,3] == listShow (fromList [1,2,3])
True

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

Александр Ручкин