Содержание

Haskell: введение в функциональное программирование

Автор: к.ф.-м.н., доцент кафедры информатики В.Н. Власов


удобный для просмотра вид

Ссылки на важные ресурсы по Haskell

  1. Основной сайт разработчиков (англ.) Haskell.org (есть небольшой раздел на русском языке).
  2. Сайт с переводом основной документации по синтаксису языка (рус.) Haskell-98.
  3. Ряд статей на сайте RSDN.org (рус.) о декларативном, функциональном программировании и о Haskell, в частности Декларативное программирование . Особый интерес для начинающих представляют статьи Мягкое введение в Haskell-1 и Мягкое введение в Haskell-2, Функциональные типы и композиция функций в Хаскелле.МОНАДЫ!!!
  4. Цикл переводов «Еще Одно Руководство по Монадам» от Mike Vanier в 4-х частях: Основы, Функции '>>=' и return, Монадные законы, Монада Maybe и монада списка (возможно, самое лучшее введение по монадам на русском языке). Оригиналы и ещё 4-е статьи смотреть тут Mike's World-O-Programming;
  5. «Жесткое введение» в Haskell Через тернии к Haskell (перевод). 2/2;
  6. Официальная документация на модуль Prelude
  7. Учебник по Haskell. Антон Холомьёв (пока лучший учебник на русском!!)

Урок 1. Знакомство с интерпретатором ghci

Запуск интерпретатора Haskell осуществляется командой ghci. Он имеет свою собственную оболочку, похожую на консольную оболочку Linux, однако предназначенную для вычислений выражений, функций, задания новых определений, загрузки модулей и собственных скриптов, и только отчасти в ней возможно выполнение некоторых функций обычной оболочки вроде смены директории.

Полезные команды в ghci:

:q – выйти из оболочки;

:l <имя программы> – загрузить программу с указанным именем. Программа может не содержать модуля Main, а только определения функций (что мы и будем делать первое время);

:r – перегрузить текущий модуль;

:t <имя функции> – напечатать сигнатуру функции;

:i <имя функции> – напечатать сигнатуру функции и указать, в каком файле была определена данная функция;

Кроме того, надо знать, что выражения в оболочке интерпретатора можно сразу вычислять. Наберем 2 + 2 и нажмем Enter, или наберем abs (-2) и нажмем Enter, и т.д.

Новые функции (пользовательские) можно задавать, как уже писалось выше, в отдельных файлах и подгружать по мере необходимости, либо сразу определять в оболочке:

> let {f :: Int -> Int; f n = n * 2}

Отметим, что в версиях ghc/ghci, начиная с 8.0.2, ключевое слово let писать необязательно.

Многострочные записи делают следующим образом:

> :{
| let f :: Int -> Int
|     f n = n * 2
> :}

Другой вариант определения многострочников:

> :set +m:
| let f :: Int -> Int
|     f n = n * 2

Для просмотра тех или иных значений функции f можно попробовать набрать f 4, а для просмотра произвольного значения foo набрать show foo.

Дополнительно отметим, что командами вида :!cmd можно вызвать команду шелла cmd (например ls в Linux или dir в Windows).

Урок 2. Способы определения функций

Определим факториал. В качестве необязательного начала укажем его сигнатуру. Она задает область определения и значения данной функции. В нашем случае – это потенциально бесконечное множество целых чисел. Можно попробовать вычислить факториал от 10000 или от даже большего аргумента. Для сравнения можно изменить лишь сигнатуру в определении этой функции на Int -> Int (или прямо в ghci введем новую функцию: let {f :: Int -> Int; f 1 =1; f n = n * f(n-1)}) и мы уже вряд ли сможем посчитать факториал с аргументом более 31.

Остальной код задан в виде уравнений, которые через сопоставление с образцами (pattern matching) задают нужное значение функции. Образцы рассматриваются по порядку. Возможно использование рекурсии при описании функции. При вызове функций им передаются из аргументов выражения, вычисляемые по необходимости и это следует учитывать особенно при программировании рекурсивных функций, так возможен большой расход оперативной памяти (частично это как раз преодолевается грамотным использованием так называемой ленивости вычислений)

fact :: Integer -> Integer
fact 0 = 1
fact n = n * fact (n-1)

В следующем определении мы установим «охрану» (guards), т.е. зададим ряд условий, которые также будут просматриваться по порядку. Если условие True, то считается выражение после равенства, если False, то просматривается следующее условие. При этом условия могут пересекаться или даже не покрывать все возможные ситуации. Если все условия будут просмотрены и ни одно из них не даст True, то получим ошибку времени выполнения.

Для получения сведений о типе функции, в интерпретаторе можно задать :t fact' или для более подробных сведений :i fact'.

Константа otherwise равна True и используется для удобства как синоним.

fact' x | x <= 1    = 1
        | otherwise = x * fact' (x-1) 

Упражнение. Используя сопоставление с образцами, а затем охрану, создать программу, вычисляющую функцию «знак числа»:

1. заданную для x >= 0:
sign (x) = 0, если x=0
sign (x) = 1, иначе
2. заданную для любого x:
sign (x) = -1, при x<0
sign (x) =  1, при x>0
sign (x) =  0, иначе

2.1 Ветвления

В Haskell возможно описание вычисления функции в зависимости от условий в более-менее традиционном ключе, для чего присутствуют операторы if и case. Однако, их применение отлично от аналогичных операторов в императивных языках. (Правильнее их называть выражениями с использованием case- и if-then-else-конструкций)

Тем не менее, семантика данного оператора в Haskell похожа на семантику тернарного оператора
у = х > 9 ? 100 : 200;

в Си-подобных языках программирования.

Ключевое слово if предназначено для указание на ветвление вычислительного процесса в зависимости от условия булевского типа. Части then и else обязательны, они в отличие от императивного аналога задают не порядок действий, а функции, которые возвращают результат для задаваемой функции. Данное выражение является частным, более простым случаем применения выражений с case.

Иными словами, условное выражение вида

nosign x = if x >= 0 then x else negate x

эквивалентно выражению

fcase x = case x >= 0 of 
           True  -> x
           False -> negate x

Рассмотрим более сложные примеры с case-выражениями:

casing :: Integer -> Integer -> Integer
casing x y = case (x, y) of 
               (_, 0) -> x
               (_, n) -> 1 + casing x (n-1)      

casing' :: Integer -> Integer -> Integer
casing' x y = case (x, y) of 
               (_, 0) | ( x >= 0 && y >= 0 ) -> x
               (_, y) | ( x >= 0 && y >= 0 ) -> 1 + casing' x (y-1)      
               (_,_)  | ( x < 0 || y < 0   ) -> error "cannot work with negative x and y"

Последний пример является комбинацией использования case-выражения с «охраной» для предотвращения применения функции для отрицательных аргументов.

Стоит также упомянуть, что задание функций уравнениями также возможно рассматривать как упрощенную запись с соответствующими case-выражениями.

Упражнение. Задать функцию sign(x) из предыдущего упражнения, используя ветвления с if и с case.

2.2 Карринг и лямбда-абстракция

Карринг. Отметим, что в Haskell функции f'(x,y)=x+y и f'' x y = x+y — разные по сути функции. Если f'(x,y) — это функция от одного переменного (кортежа или вектора (x,y)), то f'' x y = x+y — это функция от двух переменных. Тип у них тоже будет разный, и это можно будет узнать командой :t.

Для чего нужен карринг и такие сложности? С теоретической точки зрения, например, карринг позволяет сводить рассмотрение функций от многих переменных к функциям одной переменной. На практике карринг позволяет задавать функции неполным применением, делать так называемые сечения.

Рассмотрим выше заданную функцию f'' x y, назовем ее более логично add x y.

add x y = x + y

Тогда мы легко можем задать частный случай сложения, функцию-инкремент add1 t = 1+t просто частичным применением уже заданной функции add

add1  = add 1

Лямбда-абстракции. Данный синтаксис прежде всего предназначен для описания анонимных функций и случаев, аналогичных частичному применению (функций). Однако полезен и для обычного описания функций.

mi2  = \x -> (x-2)
 
trio :: Integer -> Integer -> Integer -> Integer
trio = \x y z -> x*y + z

Примеры частичного применения функции ниже показывают, что если мы даем частичное определение функции без последнего аргумента (каррированной функции), то получается естественное частичное применение. Если мы хотим оставить в «свободном плавании» первый или второй аргумент, то мы вынуждены использовать лямбда-абстракцию.

duo'  = trio 2 3
duo'' = \t -> trio 2 t 3

Если мы подставим в качестве аргумента 4, то увидим разницу значений функций duo' и duo''.

В следующем, более простом примере, функции padd1 и padd3 эквивалентны, хотя заданы различными способами (можно запустить функции для проверки с аргументами 4 и 5):

-- add x y = x+y
padd  = \x y -> x^2 + y
padd1 t  = \x   -> padd t x
padd2 t  = \x   -> padd x t
padd3 x  = padd x

Необходимость в анонимных функциях возникает, например, при использовании функций высокого порядка (т.е. таких, которые в качестве аргумента сами получают функции). Например, рассмотрим функцию map, которая применяет полученную в качестве аргумента функцию к списку, полученному в качестве второго аргумента. Например, мы могли бы передать этой функции уже готовую функцию, а могли бы задать ее «на лету». Для просмотра результатов в ghci наберите show l1 и show l2.

l1 = map abs [(-1), 2, (-4.2)]
l2 = map (\x -> if x >= 0 then x else negate x) [(-1), 2, (-4.2)]

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

l3 = 
  map ((\x -> if x >= 0 
               then x 
               else negate x)::(Double -> Double)) 
    [(-1), 2, (-4.2)]

2.3 Замыкания (локальные определения)

Аналогами локальных присваиваний в императивных языках, в функциональном языке Haskell есть два способа порождать локальные определения.

let-выражения.

roots a b c =
 let d = b^2 - 4*a*c
     sd = sqrt d
     x1 = (-b - sd) / (2*a)
     x2 = (-b + sd) / (2*a)
 in (x1,x2)

where-конструкция. Данная конструкция не является выражением, она является частью синтаксиса объявления функций и case-выражений. Хотя в применении они очень похожи!

roots' a b c = (x1,x2) where
 d = b^2 - 4*a*c
 sd = sqrt d
 x1 = (-b - sd) / (2*a)
 x2 = (-b + sd) / (2*a)

Пример вычисления корней квадратного уравнения с ветвлением.

При упрощённом подходе мы не отслеживаем случаи, когда старший коэффициент будет равен нулю или дискриминант будет отрицательный. Однако в некоторых случаях это будет приводить к аварийному останову программы в процессе вычисления. Мы можем эти случаи отследить заранее и реагировать, например, более осмысленными предупреждениями:

roots' a b c | ((a == 0) && (b == 0)) = error "No roots at all!"
             | ((a == 0) && (b /= 0)) =  (x,x)  -- let x = -c/b in (x,x)  
             | (a  /= 0)  = (x1,x2) 
  where  
    d  = b^2 - 4*a*c
    sd = if (d<0) then error ("No real roots! d=" ++ show d)
           else sqrt d  
    x1 = (-b - sd) / (2*a)
    x2 = (-b + sd) / (2*a)
    x  = -c/b

2.4 Операторы композиции функции и применения в Haskell

Два странных (для программистов из «другого» мира) оператора «.» и «$». Сначала рекомендуется о них прочесть статьи:

Функциональные типы и композиция функций в Хаскелле

Еще Одно Руководство по Монадам (часть 1: основы)

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

:-) Ирония в том, что в «бесточечном стиле» оператор «точка» (.) очень даже используется, — сильнее, чем в обычном коде. Тут правильнее было бы сказать «безаргументный стиль», а не «бесточечный», так как мы опускаем аргументы функций. Mike Vanier

Вот как это можно использовать:

f x = cos (sin x)
g x = cos $ sin x
h = cos . sin

Здесь функции f, g и h задают одну и ту же композицию двух функций sin и cos.

Практичность оператора применения еще нагляднее – он так же позволяет не писать лишних скобок, вот еще то же самое определение

f x = cos $ sin x

2.5 Проблемы преобразования типов в выражениях

Попробуем

2 + 2.0
4.0

Теперь попробуем

let x = 2 
x + 2.0
 
<interactive>:1:4:
    No instance for (Fractional Integer)
      arising from the literal `2.0' at <interactive>:1:4-6

Почему ошибки не было в первом случае, и откуда она взялась во втором? И как ее избежать? Ведь в других языках приведение констант делает автоматически…

В новых версиях ghci поведение в let изменено. Если раньше, по крайней мере до GHCi, version 7.4.1 было:
Prelude> :t x
x :: Integer

то теперь в GHCi, version 8.0.2

Prelude> :t x
x :: Num t => t

соответственно получается правильный ответ:

Prelude> x + 2.0
4.0
Как показали дальнейшие изыскания, изначальное поведение было обусловлено ещё одной проблемой Haskell, так называемом ограничением мономорфизма: Monomorphism_restriction. В современных версиях ghci это ограничение отключено, но при желании его можно вновь включить и потестировать указанные выше странности:
Prelude> :set -XMonomorphismRestriction
Prelude> let x = 2
Prelude> x + 2.0

<interactive>:5:5: error:
    * No instance for (Fractional Integer)
        arising from the literal `2.0'
...

Вот версия более простого и понятного примера, без странностей ограничения мономорфизма:

Prelude>let x=2::Int; y=2::Double
Prelude> x+y
<interactive>:2:3: error:
    * Couldn't match expected type `Int' with actual type `Double'
    * In the second argument of `(+)', namely `y'
      In the expression: x + y
      In an equation for `it': it = x + y

В языках типа Си это соответствовало бы объявлению и инициализации переменных типа int и double, а потом совместного их использования в одном арифметическом выражении.

2.6 Использование монады состояния

Пока без комментариев, вычисление факториала:

import Control.Monad.State
 
fact' :: Integer -> State Integer Integer 
-- тип состояния - Integer, тип результата - тоже Integer
fact' 0 = do
    acc <- get -- получаем накопленный результат
    return acc -- возвращаем его
fact' n = do
    acc <- get -- получаем аккумулятор
    put (acc * n) -- домножаем его на n и сохраняем
    fact' (n - 1) -- продолжаем вычисление факториала
 
-- fact :: Integer -> Integer
fact n = fst $ runState (fact' n) 1 -- начальное значение состояния = 1

<html><hr></html>

Задания к уроку

1. Используя примеры из урока, написать код и исполнить его в ghci, проверить тип полученных функций.

2. Сделать тоже самое в отдельном файл и загрузить его в ghci с помощью команды :l.

3. Написать код для функций min, max, |x|, [x], среднего арифметического двух чисел и списка чисел, многочленов, рациональных функций и др. числовых функций.

Урок 3. Списки как типы

Списки — широко используемая в Haskell (и в др. функц. языках в целом) полиморфная структура данных. Например, [a] — это семейство типов, состоящее из типов списка с элементами из a, для любого типа a. Список целых (напр., [1,2,3]), список символов (['a','b','c']), даже список списков целых и т.д. – все являются членами этого семейства (отметим, однако, что [2,'b'] не является таким примером и списком).

Список [1,2,3] в Haskell на самом деле является сокращенной формой записи для списка 1:(2:(3:[])), где [] — это пустой список, : — инфиксный оператор, добавляющий свой первый аргумент в начало своего второго аргумента (некоторого списка). Поскольку : правоассоциативен, мы можем написать 1:2:3:[].

3.1 Полезные базовые функции для работы со списками

Рассмотрим ряд полезных базовых функций.

(везде ниже xs, ys и т.п. обозначают список)

3.2 Добавление к списку

Ряд функций для добавления как отдельных элементов, так и целых списков.

Если же нам необходимо вставить x на произвольную позицию n, надо разбить список на два меньших куска, поместить новый элемент между ними и соединить все вместе обратно:

let (ys, zs) = splitAt n xs
in ys ++ [x] ++ zs

получим новый список, где x займет требуемую позицию n.

3.3 Удаление из списка

Если мы хотим удалить только n-й элемент из списка, нам придется вновь разбить список на два меньших куска, удалить нулевой элемент из второго списка и соединить все вместе обратно:

let (ys, zs) = splitAt n xs
in ys ++ (tail zs)

Задание к уроку. Напишите функцию slice, которая работает как срезы (слайсы, slices) в таких языках как Python и Perl. Другими словами, должно быть что-то типа такого

> slice 2 3 [2.3, 7.4, 5.66, 6.1, 7.0]
[5.66, 6.1]

3.4 Условия на список

Различные функции, которые извлекают подсписок в соответствии с условиями, выполняют проверки и т.п.:

Пример использования в ghci:

Prelude> let xs = [1,2,322,100]
Prelude> filter (\x -> if x>1 then True else False) xs
Prelude> [2,322,100]

3.5 Изменение списка или его элементов

3.6 Замечания о скорости работы списочных функций

Следующие функции всегда быстры:

Функции, которые имеют дело с n-м элементом или первыми n элементами, обычно имеют скорость порядка n:

Все функции, которые для выполнения нуждаются в целом списке, становятся тем медленнее, чем длиннее становится список:

Задача 1. Написать собственные реализации функций maximum, minimum, sum, filter, reverse, sort.

3.7 Определитель списков и диапазоны

По аналогии с математической теории множеств, в которой множество можно задать из имеющего с помощью аксиомы выделения:

{ 2x | x ∈  N }

где мы задаем множество четных натуральных чисел, в Haskell мы можем задать список четных натуральных чисел:

[ 2*x | x <- [1..] ]

(отметим, заданный список будет бесконечный!)

После выражений вида x <- xs, называемых генератором, могут быть дополнительные условия (охрана), которые также должны быть выполнены:

[ 2*x | x <- [1..], 3 < x, x < 7 ]

Отметим, что хотя с математической точки зрения данный список будет формально заданным, его вычисление, быстро выдав три первых ответа: 8,10,12 — зависнет, так как реально программа будет перебирать все натуральные значения x, начиная с 1. Поэтому запуск можно делать как

take 3 [ 2*x | x <- [1..], 3 < x, x < 7 ]

либо как указано выше, вводить ограничение сверху, т.е. использовать x ← [1..57].

В примере также показан способ построения списков, называемых диапазон:

Простые задачи. Описать списки четных и нечетных чисел, список квадратов четных чисел, список кубов нечетных. Выбрать из них список чисел, делящихся на 7, от 2000 до 10000.

Задача 1. Написать код, генерирующий потенциально бесконечный список чисел Фибоначчи. Рассмотреть возможности «лобового решения» с бинарной рекурсией и оптимизации. (см. варианты решения)

Задача 2. Написать код, генерирующий потенциально бесконечный список простых чисел. Оптимизация по скорости, памяти и краткости кода не требуется. (см. решение здесь)

3.8 Дополнительные функции из модуля Data.List

Для целей «разгрузки» базового модуля Prelude в плане работы со списками, создан модуль Data.List, который содержит помимо основных дополнительные удобные функции. Модуль необходимо вызывать квалифицированно:

import qualified Data.List

либо явно указывать импортируемые функции из числа тех, что не определены в Prelude:

import Data.List(delete)

Перечислим ряд полезных функций, эмулирующих работу со списками как со множествами:

Задание к уроку. Сделать собственные реализации указанных функций для представления конечных множеств в виде конечных списков.

3.8.2 Поиск и замена в списках

Модуль Data.List предоставляет ряд функций, полезных для реализации функций поиска и замены в списках. Помимо уже указанных (!!), elem есть следующие:

Задача 1. В Haskell нет встроенных функций по типу index, substr, replace для работы с поиском и заменой. Написать собственные реализации.

3.9 Различные примеры определения функций, работающих со списками

Хорошим собранием образцов таких определений функций является сам модуль Prelude. Для нужд обучения полезно писать собственные реализации таких функций и/или изучать готовый код.

last [x]    = x
last (_:xs) = last xs
 
init [x]    = []
init (x:xs) = x:(init xs)
 
head (x:_)  = x
 
tail (_:xs) = xs
 
null []     = True
null (_:_)  = False
 
length []   = 0
length(x:xs)= 1 + length xs

Функция length, заданная выше, в некотором смысле является образцом неэффективности рекурсивного определения для часто используемой функции, реализация которой в императивных языках для массивов и динамических массивов легковесна и быстра. В самом деле, напр. при вычислении длины списка в 1000000 элементов, функция length 1000000 раз пересоздаст список, убавляя каждый раз его на 1 элемент, и все промежуточные вычисления вместе со ссылками на адреса предыдущих шагов должны присутствовать в оперативной памяти, пока не будет явно вычислен случай пустого списка, и вычисление не вернется по шагам обратно.

Для преодоления такой ситуации в функциональном программировании везде где это необходимо используют хвостовую рекурсию и накапливающийся параметр:

length' [] n = n
length' (_:xs) n = length' xs (n+1)

length ys = length' ys 0 

Здесь length' определена как хвостовая рекурсия, т.е. рекурсия, в определении которой последний вызов является вызовом самой себя (хвостовой вызов) и таким образом, позволяет избежать накладных расходов. Кроме того, такие определения при трансляции компилятором преобразуется в цикл типа for.

Задание 1 для самостоятельной работы: написать собственный код для всех функций работы со списками, приведенных в главе 3.

Задание 2: написать код функции транспозиции, т.е. для данного списка xs и данных номеров-параметров n,m поменять элементы xs!!n xs!!m местами. Иными словами, в императивном смысле нам необходимо сделать что-то подобное следующему:

tmpn := xs[n]; tmpm := xs[m];
xs[n]:= tmpm;  xs[m]:= tmpn;

Однако, в случае Haskell, функции необходимо вернуть новую копию списка, в которой указанные элементы будут поменяны местами.

Урок 4. Модули

4.1 Импорт модулей

Импортирование сторонних модулей производится при помощи ключевого слова import. После этого ключевого слова должно располагаться наименование импортируемого модуля, после которого может находиться перечисление тех программных сущностей, которые импортируются в модуль. Если такого списка нет, то импортируется все экспортируемое данным модулем. Например:

import Data.List
import Data.Char (
  toLower,
  toUper
)

SIC. Экспорт воплощения экземпляров классов делается всегда, вне зависимости от списка — попросту, нет механизма внесения в список.

А что делать, если возникает конфликт имен при импорте? Рассмотрим возможные решения ниже.

4.1.1 Сокрытие имен при импорте модулей

Иногда бывает проще при импорте скрыть некоторые имена, чем перечислять то, что импортируется. Это можно сделать при помощи ключевого слова hidding, которое должно располагаться после наименования модуля при импорте. После этого перечисляются имена, которые должны быть скрыты при импорте:

import MyModule hiding (
someFunction1, someFunction2
)

4.1.2 Квалифицированный импорт моду