Haskell. Основы

Haskell — достаточно необычный язык. И несмотря на немалое количество статей по нему, нередко можно столкнуться с мнением, что всё это помогает лишь в синтетических примерах. И действительно: на простых примерах всё выглядит просто, но куда сложнее представить себе хотя бы небольшую программу в таком стиле, а статьи зачастую рассматривают лишь особенности языка.

02 Поэтому я захотел написать серию статей, в течение которых мы изучим возможности языка и попробуем написать простой чат. Почему именно чат? Потому что там есть место и многопоточности, и GUI клиента, и БД сервера. Хотя я с удовольствием послушал бы и ваши предложения, так как мне самому интересно, насколько этот язык удобен для решения более сложных задач.

Компилятор и интерпретатор

03 Для начала необходимо скачать и установить Glasgow Haskell Compiler (GHC). На данный момент можно взять последнюю версию, но wxHaskell, к сожалению, предоставляет бинарники для каждой платформы под разные версии GHC. В добавок, какой-то он сырой (wxHaskell), так что у меня лично GHC стоит как 6.10.1 версии, так и 6.8.3.

04 В GHC есть три основные программы: ghc — компилятор, ghci — интерпретатор, и runghc — позволяет запускать программы как скрипты без предварительной компиляции. Для редактирования исходников я использую HippoEDIT, хотя подойдёт любой редактор, где есть подсветка и можно настроить вызов сторонней программы для файла.

05 По умолчанию приглашение в ghci содержит список загруженных модулей. Чтобы не загромождать, его можно изменить:

Prelude> :s prompt "ghci> "
ghci>

06 Хаскель является чистым и ленивым функциональным языком; это значит, что результат функции зависит только от аргументов, т. е. будучи вызванной с одним аргументом, функция всегда вернёт один и тот же результат. Поэтому здесь нет переменных — есть функции и значения — однако это никак не мешает. Ленивость означает, что значение будет вычислено только тогда, когда оно действительно понадобится, что позволяет работать с бесконечными списками и структурами данных.

07 Кажется, что в таком языке ввод-вывод невозможен, однако функции, осуществляющие ввод-вывод, просто-напросто возвращают описание действия. Само описание одно и то же, т. е. функции тоже получаются чистыми. Более подробно этот механизм я рассмотрю позже.

Основные типы

08 Char — символьный тип;
Bool — True или False;
Int — целочисленное. Его размер может быть 32 бита или 64 на соответствующей архитектуре;
Integer — «бесконечное» целое;
Double — дробное (с плавающей точкой).

09 Можно проверить тип выражения при помощи команды :t.

ghci> :t ’x’
’x’ :: Char
gchi> :t True
True :: Bool
ghci> :t 5
5 :: (Num t) => t

10 Оказывается, 5 имеет тип не Int. Эта запись означает примерно «5 может иметь любой тип t, если он принадлежит классу Num». О классах я напишу позже, пока можно сделать вывод, что 5 может, в зависимости от ситуации, принимать разный тип (в том числе и пользовательский). Указать конкретный тип (не обязательно отдельного значения, можно и целого выражения) можно так:

ghci> :t 5::Int
5::Int :: Int
ghci> :t (5 + 6 * 3)::Int
(5 + 6 * 3)::Int :: Int

11 Разумеется, есть списки и кортежи. Список может содержать элементы одного типа и иметь произвольную длину, тогда как кортеж — разного, и количество элементов фиксировано в его типе.

ghci> :t (True, ’f’)
(True, ’f’) :: (BoolChar)
ghci> :t (False, 5::Int, ’q’)
(False, 5, ’q’) :: (BoolIntChar)
ghci> :t [’h’,’e’,’l’,’l’,’o’]
[’h’,’e’,’l’,’l’,’o’] :: [Char]
ghci> :t "hello"
"hello" :: [Char]

12 Из последнего можно сделать вывод, что строка — это список символов, и над ней определены все те же операции, что и над списками.

Функции

13 Чтобы применить функцию, необходимо после имени функции записать её аргументы:

ghci> odd 5
True
ghci> max 4 5
5
ghci> lines "first line\nsecond line"
["first line","second line"]
ghci> "left" ++ "right"
"leftright"

14 ++ — это оператор, но это также обычная функция, которую можно использовать и так:

ghci> (++) "left" "right"
"leftright"

15 Посмотрим тип функции:

gchi> :t lines
lines :: String -> [String]

16 Он записывается через стрелку и означает, что lines берёт строку, а возращает список строк.

ghci> :t max
max :: (Ord a) => a -> a -> a

17 Таким образом, max определён для любого a, принадлежащего к классу Ord, принимает два аргумента одного типа и возвращает результат того же типа. Но тип функции можно представить и так:

max :: (Ord a) => a -> (a -> a)

18 И тут мы встречаем currying. Получается, что max принимает один аргумент и возвращает функцию типа (a -> a). Любую функцию можно представить, как принимающую один аргумент и возвращающую другую функцию.

ghci> (max 2) 4
4

19 То же верно и для операторов:

ghci> ((++) "left""right"
"leftright"

20 Но в случае оператора можно записать и так:

ghci> ("left" ++) "right"
"leftright"
ghci> (++ "right""left"
"leftright"

21 Т. е. тут важно положение аргумента, и можно передать сначала второй. Это всего лишь синтаксический сахар, но довольно удобный. Самое интересное, что то же самое можно сделать и с обычной функцией, если заключить её в кавычки-грависы `:

ghci> 2 `max` 4
4
ghci> (2 `max`) 4
4

23 Определим свою функцию:

ghci> let max2 = max 2

24 Несмотря на то, что язык строгий, зачастую аннотации типов можно опустить, так как Хаскель умеет выводить типы. Однако, если посмотреть тип max2, мы увидим нечто странное:

ghci> :t max2
max2 :: Integer -> Integer

25 Куда-то подевалось a, и тип стал фиксирован. Честно говоря, не скажу, почему именно такое решение выбрано, однако этого можно избежать, либо явно указав полиморфный тип, либо определив функцию так: (let нужен только для интерпретатора, в исходном коде для определения функции он не нужен)

ghci> let max2 x = max 2 x
ghci> :t max2
max2 :: (Ord t, Num t) -> t -> t
ghci> max2 3.6
3.6

26 Функции высшего порядка (ФВП) могут принимать другие функции и возвращать их. Например, мы можем определить функцию, которая принимает две и применяет обе к некоторому аргументу:

ghci> let applyFunc f1 f2 x = f1 (f2 x)
ghci> applyFunc (*3) (/2) 5
7.5

27 В качестве первой функции мы передаём (*3), т. е. функцию, которая принимает число и умножает его на 3, в качестве второй — функцию, делящую на два. В результате применения их последовательно к 5, мы и получаем 7.5.

ghci> :t applyFunc
applyFunc :: (t1 -> t2) -> (t -> t1) -> t -> t2

28 По типам можно даже догадаться, что делает эта функция. Она принимает две функции: из t1 в t2 и из t в t1, а возвращает функцию из t в t2. Понятно, что в результате мы получим функцию, которая должна последовательно применить обе эти функции (так как сам тип неизвестен, и ничего другого сделать нельзя). Эта функция уже определена в стандартной библиотеке и называется она . (точка):

ghci> (not.odd) 5
False
ghci> (not.odd.(+1)) 5
True

29 В первом случае сначала применяется функция odd, а затем not. Ну а во втором изначально добавляется единица. В данном случае это кажется ненужным, но вспомним, что функции могут принимать другие функции, и когда требуется «на лету» создать сложную функцию из примитивов, то такие композиционные операции очень пригождаются. Ещё одна такая полезная функция flip, меняет местами аргументы:

ghci> (-) 2 3
-1
ghci> (flip (-)) 3 2
-1

Операции над списками

30 Рассмотрим основные списочные операции. Список состоит из головы и хвоста и создаётся конструктором : (двуеточие).

ghci> 5:6:[]
[5,6]

head :: [a] -> a
tail :: [a] -> [a]

31 Эти операции позволяют получить соответственно голову и хвост. Передавать пустой список туда нельзя, иначе вылетит исключение. Как это сочетается с чистотой, я потом тоже расскажу.

32 last :: [a] -> a
init :: [a] -> [a]

— Возвращают последний элемент и список без последнего элемента.

33 take :: Int -> [a] -> [a]

— Возвращает первые n элементов.

34 drop :: Int -> [a] -> [a]

— Отбрасывает первые n элементов и возвращает оставшиеся.

35 null :: [a] -> Bool -- проверяет список на наличие элементов
length :: [a] -> Int -- возвращает количество элементов
reverse :: [a] -> [a] -- переворачивает список
map :: (a -> b) -> [a] -> [b]

36map принимает функцию и применяет её к каждому элементу списка, на выходе мы получаем новый список:

ghci> map (*2) [1, 2, 3] [2,4,6]

37 Подключим модуль Data.Char: (там определена, в частности, функция toUpper)

ghci> :m + Data.Char
ghci> map toUpper "hello"
"HELLO"

38 filter :: (a -> Bool) -> [a] -> [a]

— Принимает предикат и оставляет только те элементы, которые ему удовлетворяют.

foldl :: (a -> b -> a) -> a -> [b] -> a
foldr :: (a -> b -> b) -> b -> [a] -> b

39 Свёртку проще показать на примере:

ghci> foldr (+) 0 [1, 2, 3, 4]
10

40 Т. е. мы передаём функцию, при помощи которой список будет свёртываться, начальное значение и сам список. В результате мы получаем:

f (f (f (f 0 1) 2) 3) 4 -- foldl
f 4 (f 3 (f 2 (f 1 0))) -- foldr

41 В терминах этой функции можно определить множество остальных:

product :: Num a => [a] -> a
product = foldr (*) 1
maximum lst = foldr max (head lst) (tail lst)

42 Есть ещё множество полезных функций, полный их список можно посмотреть в документации.

43 В отношении функций термины «принимает» и «возвращает» не совсем точны, так как функция не вычисляется сразу из-за ленивости, но более подходящих слов я не нашёл.

44 Для списка определён удобный синтаксический сахар:

ghci> [1..10]
[1,2,3,4,5,6,7,8,9,10]
ghci> [1,3..10]
[1,3,5,7,9]
ghci> ['a'..'d']
"abcd"

45 Причём, опять же, в качестве начала и конца могут выступать и пользовательские данные. Но пока что об этом рановато. Интересно, что правую границу можно опустить, и мы получим бесконечный список.

Бесконечные списки

46 ghci> [1..]
[1,2,3,4Interrupted.

47 Чтобы это остановить, нажмите Ctrl-C. На самом деле, у меня успело вывести до 622, но я тут этого отражать не стал. С этим списком можно работать как обычно. Например, можно возвести каждый элемент в квадрат, взять только чётные.

ghci> let lst = [1..]
ghci> let filteredLst = filter even (map (^2) lst)
ghci> take 10 filteredLst
[4,16,36,64,100,144,196,256,324,400]

48 Конечно, вычислить длину такого списка или развернуть его (reverse) не получится, но это, как правило, и не нужно.

ghci> (last.reverse) lst

49 Здесь нам тоже не повезёт. Хотя мы-то с вами понимаем, что последний элемент перевёрнутого списка — это первый элемент изначального, компилятор до этого догадаться не может; а ленивость в данном случае не помогает. Бесконечный список можно определить и через корекурсию — операцию, подобную рекурсии, но не свёртывающую структуру данных (уменьшение значения аргумента тоже можно назвать свёртыванием), а «развёртывающую» результат (Total FP) на основе изначальных аргументов:

ghci> let ones = 1 : ones
ghci> take 5 ones
[1,1,1,1,1]

50 Благодаря ленивости, полученная бесконечная структура данных (в данном случае ones — это список, в голове которого находится 1, а в хвосте — сам ones, т. е. это бесконечный список единиц) не вычисляется без необходимости, и ей можно пользоваться как обычно. Где это может пригодиться на практике? Например, в строгих языках необходимо заранее знать количество элементов списка, что заставляет в месте создания списка знать лишнюю деталь реализации. Либо приходится создавать какой-нибудь генератор, который вызовут уже «наверху». Здесь же мы можем просто создать бесконечный список, а нужную часть из него возьмут тогда, когда она потребуется.

51 Ну а теперь зарядка для ума:

ghci> let substr from len str = take len (drop from str)
ghci> substr 2 3 "01234567"
"234"

52 Сделаем преобразования:

substr from len str = (take len . drop from) str

53 Опускаем str слева и справа:

substr from len = take len . drop from
substr from len = (.) (take len) (drop from)

54 Меняем местами (take len) и (drop from), чтобы len оказался справа:

substr from len = flip(.) (drop from) (take len)

55 Сначала к len применяется take, потом (flip(.) (drop from)). Запишем это с использованием (.):

substr from len = ((flip(.) (drop from)).take) len

56 Убираем len:

substr from = (flip(.) (drop from)).take

57 Точку перед take выносим в начало:

substr from = (.) (flip(.) (drop from)) take

58 Меняем местами аргументы, чтобы выражение, содержащее from, стояло справа:

substr from = flip(.) take (flip(.) (drop from))

59 К from применяются последовательно: drop, flip(.), (flip(.)take), так и запишем:

substr from = ((flip(.) take).flip(.).drop) from

60 Убираем from:

substr = (flip(.)take).flip(.).drop

61Проверим:

ghci> let substr = (flip(.)take).flip(.).drop
ghci> substr 2 3 "01234567"
"234"

Практического значения это, конечно, не имеет, но по-моему достаточно забавно :-)

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