Haskell MOOC, часть 2

Джоэла Каасинена (Nitor) и Джона Лонга (Университет Хельсинки)

9 Лекция 9: повторение части 1

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

9.1 Типы

Помните примитивные типы Хаскелла? Вот они:

Примеры значений Тип Описание
True, False Bool Логические значения
0, 1, 20, -37, … Int Целые числа
'A', 'a', '!', … Char Символы
"", "abcd", … String Строки, которые в действительности являются списками символов [Char]
0.0, -3.2, 12.3, … Double Числа с плавающей точкой
() () Единичный тип (unit type) с единственным значением

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

Примеры значений Тип Описание
(1,2), (True,'c'), … (a, b) Пара из значений типов a и b
(1,2,3), (1,2,'c'), … (a, b, c) Тройка значений (типов a, b и c)
[], [1,2,3,4], … [a] Список значений типа a
not, reverse, \x -> 1, \x -> x, … a -> b Функция от типа a до типа b

Есть еще один мощный механизм для создания большего количества типов: Алгебраические типы данных (ADTs). Некоторые примеры включают:

-- Enumeration types
data Bool = True | False
data Color = Red | Green | Blue

-- Record types that contain fields
data Vector2d = MakeVector Double Double
data Person = Person Int String

-- Parameterized types. Note the type parameter `a`
data PairOf a = TwoValues a a

-- Recursive types
data IntList = Empty | Node Int IntList

-- Complex types which combine many of these features
data Maybe a = Nothing | Just a
data Either a b = Left a | Right b
data List a = Nil | Cons a (List a)             -- This is equivalent to the built-in [a] type
data Tree a = Leaf a | Node a (Tree a) (Tree a)
data MultiTree a = MultiTree a [MultiTree a]     -- Note the list

Ценности этих типов включают:

Значения Тип
True, False Bool
Red, Green, Blue Color
MakeVector 1.5 3.2 Vector2d
Person 13 "Bob" Person
TwoValues 1 3 PairOf Int
Empty, Node 3 (Node 4 Empty) IntList
Nothing, Just 3, Just 4, … Maybe Int
Nothing, Just 'c', Just 'd', … Maybe Char
Left "foo", Right 13, … Either String Int
Nil, Cons True Nil, Cons True (Cons False Nil) ... List Bool
Leaf 7, Node 1 (Leaf 0) (Leaf 2), … Tree Int
MultiTree 'a' [MultiTree 'b' [], MultiTree 'c' []]], ... MultiTree Char

Вы можете комбинировать параметризованные типы сложными способами, например, с чем-то вроде Either [String->String] (Maybe String, Int).

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

[a] -> [a]    -- function from list of any type, to list of the same type
[a] -> a      -- function from list of any type, to the element type
(a,b) -> [a]  -- function from tuple to list

9.1.1 Подробнее о списках

Литералы списка могут быть написаны с использованием знакомого синтаксиса [x,y,z]. Тем не менее, эта нотация является лишь сокращением, поскольку списки фактически составлены из списков конструкторов [] и (:). Эти конструкторы также используются при сопоставлении списков шаблонов. Вот некоторые примеры списков:

Сокращения Полный список Тип
[1,2,3] 1:2:3:[] [Int]
[[1],[2],[3]] (1:[]):(2:[]):(3:[]):[] [[Int]]
"foo" 'f':'o':'o':[] [Char], также известный как String

Существует также синтаксис диапазона для списков:

диапазон Результат
['a' .. 'z'] "abcdefghijklmnopqrstuvwxyz"
[0 .. 9] [0,1,2,3,4,5,6,7,8,9]
[0, 5 .. 25] [0,5,10,15,20,25]
[x .. y] Все от x до y
[9 .. 3] []
[y, y-1 .. x] от всего, что y то x в порядке убывания
[9,8 .. 3] [9,8,7,6,5,4,3]

List Comprehensions - еще один мощный способ создания списков:

Понимание Результат
[x^3 | x <- [1..3]] [1,8,27]
[x^2 + y^2 | x <- [1..3], y <- [1..2]] [2,5,5,8,10,13]
[y | x <- [1..10], let y = x^2, even x, y<50] [4,16,36]
[c | c <- "Hello, World!", elem c ['a'..'z']] "elloorld"

В общем случае [f x | x <- xs, p x] эквивалентно map f (filter p xs). Аналогично, [y | x <- xs, let y = f x] эквивалентно [f x | x <- xs]. В генераторах списков можно сочетать <-, let и выражение результата [f x | ...].

Еще одно замечание по синтаксису. Напомним, что (:) ассоциируется справа, например, True:False:[] совпадает с True:(False:[]). (На самом деле, (True:False):[] даже не является списком, потому что True:False пытается добавить True перед False, который не является списком.)

9.2 Функции

Основной формой определения функции является:

functionName :: argumentType -> returnType
functionName argument = returnValue

Например:

repeatString :: String -> String
repeatString s = s ++ s

Функции, принимающие несколько аргументов, определяются аналогичным образом. Обратите внимание, как выглядит тип функции с несколькими аргументами.

surroundString :: String -> String -> String
surroundString around s = around ++ s ++ around

Функции могут быть полиморфными, могут принимать множество аргументов и даже могут принимать функции в качестве аргументов. Вот еще несколько примеров:

id :: a -> a
id x = x

const :: a -> b -> a
const x y = x

flip :: (a -> b -> c) -> b -> a -> c
flip f x y = f y x

Более сложные функции могут быть определены с помощью pattern matching. Мы можем сопоставить шаблоны на конструкторах алгебраических типов данных, таких как Maybe, а также списки конструкторов [] и (:).

swap :: (a,b) -> (b,a)
swap (x,y) = (y,x)

maybe :: b -> (a -> b) -> Maybe a -> b
maybe def _ Nothing  = def
maybe _   f (Just x) = f x

safeHead :: [a] -> Maybe a
safeHead []    = Nothing
safeHead (x:_) = Just x

Еще больше изощренности можно достичь с помощью guards. Охрана позволяет определить функцию в каждом конкретном случае на основе тестов типа Bool. Охрана полезна в ситуациях, когда сопоставление шаблонов не может быть использовано. Конечно, защитные выражения (guards) также могут сочетаться с сопоставлением шаблонов:

myAbs :: Int -> Int
myAbs x
  | x < 0     = -x
  | otherwise = x

safeDiv :: Double -> Double -> Maybe Double
safeDiv x y
  | y == 0    = Nothing
  | otherwise = Just (x / y)

buy :: String -> Double -> String
buy "Banana" money
  | money < 3.2   = "You don't have enough money for a banana"
  | otherwise     = "You bought a banana"
buy product  _    = "No such product: " ++ product

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

divDefault :: Double -> Double -> Double -> Double
divDefault x y def = case safeDiv x y of
  Nothing -> def
  Just w  -> w

Выражения позволяют локальные определения где пункты работают аналогично let С. Например:

circleArea :: Double -> Double
circleArea r = let pi = 3.1415926
                   square x = x * x
               in pi * square r

circleArea' :: Double -> Double
circleArea' r = pi * square r
    where pi = 3.1415926
          square x = x * x

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

incrementAll :: [Int] -> [Int]
incrementAll xs = map (\x -> x + 1) xs

Отметим, что f x = y - это то же самое, что и f = \x -> y.

Наконец, бинарные операторы имеют sections. Секции частично применяются операторами. Раздел оператора получается путем написания оператора и одного из его аргументов в скобках. Например, (*2) умножает аргумент 2 справа, например, (*2) 5 ==> 5 * 2. Дробное число (например, Double) можно перевернуть с помощью раздела (1/), например (1/) 2 ==> 0.5.

incrementAll' :: [Int] -> [Int]
incrementAll' xs = map (+1) xs

9.3 Функциональное программирование

Haskell - это функциональный язык программирования, который означает, что функции могут быть переданы в виде аргументов и возвращены из функций. Как парадигма программирования, функциональное программирование направлено на создание программ путем объединения простых функций вместе, чтобы сформировать все большие и большие.

Наиболее часто представленным примером функционального программирования является манипуляция функциональным списком. функции высшего порядка (функции, которые принимают функции в качестве аргументов) map и filter Вот один пример из части 1:

-- a predicate that checks if a string is a palindrome
palindrome :: String -> Bool
palindrome str = str == reverse str

-- palindromes n takes all numbers from 1 to n, converts them to
-- strings using show, and keeps only palindromes
palindromes :: Int -> [String]
palindromes n = filter palindrome (map show [1..n])
palindromes 150
  ==> ["1","2","3","4","5","6","7","8","9",
       "11","22","33","44","55","66","77","88","99",
       "101","111","121","131","141"]

В части 1 мы встретили и другие приёмы функционального программирования, например частичное применение (partial application):

map (take 3) [[1,2,3,4,5],[6,7,8,9,0]]
  ==> [[1,2,3],[6,7,8]]

Кроме того, функциональная композиция:

(map reverse . filter (/="Smith")) ["Jones","Smith","White"]
  ==> ["senoJ","etihW"]
map (negate . sum) [[1,2,3],[5]]
  ==> [-6,-5]

И, наконец, folds:

foldr (*) 1 [2,3,4]  ==> 24
foldr max 0 [1,3,7]  ==> 7
foldr (++) "" ["abc","de","f"] ==> "abcdef"

9.4 Рекурсия

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

repeatString :: Int -> String -> String
repeatString 0 s = ""
repeatString n s = s ++ repeatString (n-1) s

times :: Int -> Int -> Int
times 0 n = 0
times 1 n = n
times m n = n + times (m - 1) n

safeLast :: [a] -> Maybe a
safeLast []     = Nothing
safeLast [x]    = Just x
safeLast (x:xs) = safeLast xs

Для того, чтобы составить список, вам часто нужна рекурсия. Вот примеры реализации map и filter в качестве примеров обработки рекурсивных списков:

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs

filter :: (a -> Bool) -> [a] -> [a]
filter _    []     = []
filter pred (x:xs)
  | pred x         = x : filter pred xs
  | otherwise      = filter pred xs

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

sumNumbers :: [Int] -> Int
sumNumbers xs = go 0 xs
  where go sum [] = sum
        go sum (x:xs) = go (sum+x) xs

Вот последний пример использования охранников, сопоставления шаблонов, функции помощника и рекурсии:

-- split a string into pieces at the given character
mySplit :: Char -> String -> [String]
mySplit c xs = helper [] xs
  where helper piece [] = [piece]
        helper piece (y:ys)
          | c == y    = piece : helper [] ys
          | otherwise = helper (piece++[y]) ys
mySplit '-' "a-bcd-ef"  ==>  ["a","bcd","ef"]

9.5 Классы типов

Следующие функции параметрически полиморфны (parametrically polymorphic):

id :: a -> a
id x = x

head :: [a] -> a
head (x:_) = x

fst :: (a,b) -> a
fst (x,y) = x

Параметрически полиморфные функции всегда работают одинаково, независимо от того, с какими типами мы работаем. Это означает, что мы не можем определить специальную реализацию id только для типа Int или fst для типа (Bool, String).

Напротив, ad hoc polymorphism позволяет различным типам иметь разные реализации одной и той же функции. Специальный полиморфизм в Haskell может быть достигнут путем определения класса type и последующего объявления instances этого класса для различных типов. Специальный полиморфизм является удобным способом выражения общего набора операций, даже когда реализация операций зависит от типа, на который они действуют.

Функции, использующие специальный полиморфизм, имеют ограничение -класса в своих типах. Вот несколько примеров:

negate :: Num a => a -> a
(==) :: Eq a => a -> a -> Bool
sort :: Ord a => [a] -> [a]

Тип Num a => a -> a означает: для любого типа X, который является членом класса Num, эта функция имеет тип X -> X. Другими словами, мы можем вызвать negate на любом типе номера, но не на других типах:

Prelude> negate 1
-1
Prelude> negate 1.0
-1.0
Prelude> negate True
<interactive>:3:1: error:
No instance for (Num Bool) arising from a use of ‘negate’

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

  • сравнение
    • Eq - для сравнения равенства. Содержит оператора ==
    • Ord предназначен для сравнения заказов. Он содержит упорядоченные операторы сравнения, такие как < и =>, и функции, такие как max и min.
  • Числа
    • Num Для всех типов чисел. Содержит +, -, * и negate.
    • Integral предназначен для целых типов номеров. В частности, он содержит целое подразделение div.
    • Fractional для типов номеров, поддерживающих подразделение, /
  • Преобразование значений в строки
    • Show содержит функцию show :: Show a => a -> String, которая преобразует значения в строки.
    • Read содержит функцию read :: Read a => String -> a, обратную show.

Иногда требуется несколько классовых ограничений. Например, здесь:

sumTwoSmallest :: (Num a, Ord a) => [a] -> a
sumTwoSmallest xs = let (a:b:_) = sort xs
                    in a+b

Теперь, когда мы увидели некоторые классы и типы, давайте посмотрим на синтаксис объявления классов и экземпляров. Вот два определения классов:

class Sized a where
  empty :: a        -- a thing with size 0
  size :: a -> Int

class Eq a where
  (==) :: a -> a -> Bool

Рассмотрим следующие структуры данных:

data Numbers = None | One Int | Two Int Int
data IntList = Nil | ListNode Int IntList
data Tree a = Leaf | Node a (Tree a) (Tree a)

Все они имеют размеры, которые мы можем посчитать, но нам нужно выполнить операцию по-другому:

instance Sized Numbers where
  empty = None
  size None      = 0
  size (One _)   = 1
  size (Two _ _) = 2

instance Sized IntList where
  empty = Nil
  size Nil               = 0
  size (ListNode _ list) = 1 + size list

instance Sized (Tree a) where
  empty = Leaf
  size Leaf = 0
  size (Node _ left right) = 1 + size left + size right

Мы также можем легко объявить экземпляры Eq для Numbers и IntList:

instance Eq Numbers where
  None      == None       = True
  (One x)   == (One y)    = x==y
  (Two x y) == (Two z w)  = x==z && y==w
  _         == _          = False         -- to handle cases like None == One 1

instance Eq IntList where
  Nil             == Nil               = True
  (ListNode x xs) == (ListNode y ys)   = x == y && xs == ys
  _               == _                 = False

Однако, поскольку тип данных Tree параметризован по типу элемента a, нам нужен экземпляр Eq a, чтобы иметь экземпляр Eq (Tree a). Это достигается путем добавления классового ограничения к декларации экземпляра. Это называется иерархия instance..

instance Eq a => Eq (Tree a) where
  Leaf         == Leaf              = True
  (Node x l r) == (Node x' l' r')   = x == x' && l == l' && r == r'
  _            == _                 = False

9.5.1 Автоматический вывод экземпляров (deriving)

Некоторые классы стандартных типов, в частности Show, Read, Eq и Ord, могут быть derived, то есть вы можете попросить компилятор генерировать автоматические экземпляры для вас. Например, мы могли бы получить все эти классы для нашего более раннего примера Numbers.

data Numbers = None | One Int | Two Int Int
  deriving (Show, Read, Eq, Ord)
None == One 1       ==> False
Two 1 2 == Two 1 2  ==> True
None < Two 1 2      ==> True
Two 1 3 < Two 1 2   ==> False
show (Two 1 3)      ==> "Two 1 3"

9.6 Викторина

Что такое тип ('c',not)

  1. [Char]
  2. [Bool]
  3. (Char,Bool -> Bool)
  4. (Char,Bool)
  5. Это ошибка типа.

Что такое тип ['c',not]

  1. [Char]
  2. [Bool]
  3. (Char,Bool -> Bool)
  4. (Char,Bool)
  5. Это ошибка типа.

Какое из них является значением следующего типа?

data T = X Int | Y String String | Z T
  1. X "foo"
  2. Y "foo"
  3. Z (X 1)
  4. X (Z 1)

Каков тип этой функции?

f (_:Just x:_) = x
f _            = False
  1. Maybe a -> a
  2. [Maybe a] -> a
  3. [Maybe a] -> Bool
  4. [Maybe Bool] -> Bool

Каков тип этой функции?

f x y = x-y == 0
  1. (Num a, Eq a) => a -> a -> Bool
  2. Num a => a -> a -> Bool
  3. Eq a => a -> a -> Bool
  4. a -> a -> Bool

Какой из следующих типов может быть у x, чтобы x (&&) y не было ошибкой типа?

  1. Bool
  2. Bool -> Bool -> Bool
  3. (Bool -> Bool -> Bool) -> Bool -> Bool

9.7 Работа над упражнениями

Кратко повторим порядок работы над упражнениями. Он такой же, как в первой части курса.

  1. Клонируйте репозиторий GitHub: https://github.com/moocfi/haskell-mooc
  2. Перейдите в каталог exercises/
  3. Запустите stack build, чтобы загрузить зависимости
  4. Отредактируйте файл Set9a.hs
  5. Проверьте свои ответы с помощью stack runhaskell Set9aTest.hs
  6. Отправьте ответы на официальной странице отправки
  7. Повторяйте по мере необходимости. Наборы упражнений можно выполнять в любом порядке и отправлять сколько угодно раз.
  8. Общий балл доступен на официальной странице статуса

9.8 Упражнения

  • Set9a - небольшие Упражнения, которые повторяют часть 1 курса
  • Set9b - давайте решим головоломку N Queens!

10 Лекция 10: Редукционизм

  • чистота
  • лень
  • Оценка Haskell

10.1 Ленивость и чистота

Чистота и лень упоминались как ключевые черты Хаскелла в начале части 1. Давайте рассмотрим их поближе.

Haskell — чистый (pure) функциональный язык. Это означает, что значение f x y при заданных x и y всегда одно и то же. Иными словами, значения x и y однозначно определяют значение f x y. Это свойство также называют ссылочной прозрачностью (referential transparency).

Чистота также означает отсутствие побочных эффектов: вычисление f x y не может, например, прочитать строку от пользователя. При разных вызовах f строка могла бы отличаться и влиять на возвращаемое значение, нарушая ссылочную прозрачность. Разумеется, побочные эффекты необходимы для полезной работы программы. Позже мы разберём, как Haskell с ними справляется.

Haskell — ленивый (lazy) язык: значение не вычисляется, пока оно не понадобится. Лучше всего это видно на примере. Рассмотрим две функции:

f x = f x   -- infinite recursion
g x y = x

Оценка f 1 не прекращается из-за бесконечной рекурсии. Однако это работает:

g 2 (f 1)  ==>  2

Лень не является проблемой, потому что Хаскелл чист. Только результат функции имеет значение, а не побочные эффекты. Поэтому, если результат функции не используется, мы просто не можем оценить его без изменения значения (семантики) программы. Хорошо, иногда мы получаем программу прекращения вместо той, которая продолжается вечно, но добавление лени никогда не делает функциональную программу Haskell перерывом.

Если вас интересует теория, лежащая в основе этого, ознакомьтесь с теоремой Church-Rosser или статьей Haskell Wiki «Ленивость и нестрогость» (Lazy vs. non-strict).

10.2 Рассуждения с помощью равенств

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

В языке C-стиля мы можем написать процедуру, которая не всегда может возвращать одно и то же значение для одних и тех же аргументов.

int c = 0;
int funny(int x) {
  return x + c++;
}

Выражение c++ увеличивает значение c и возвращает старое значение c. В следующий раз, когда она будет оценена, значение c увеличилось на единицу. Это означает, что в зависимости от текущего значения c, funny(0) может возвращать 0, 1, 2 или любое другое целое значение. (Это может даже вернуть отрицательные значения, если c переполнится!)

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

Рассмотрим следующее выражение:

map (+1) . reverse . map (-1)

Это выражение можно упростить до reverse. Мы начинаем с установления некоторых полезных фактов (или lemmas). Прежде всего, предположим, что мы знаем

  1. map id === id
  2. map f . map g === map (f.g)
  3. reverse . map f === map f . reverse

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

  1. (+1) . (-1) === id

Мы можем доказать факт 4, рассуждая о том, как (+1) . (-1) Поведение для произвольного ввода x:

((+1) . (-1)) x === ((+1) ((-1) x))
                === ((+1) (x - 1))
                === (x - 1) + 1
                === x
                === id x

Поскольку мы ничего не предполагали об x, мы можем заключить, что приведенная выше цепочка уравнений удерживает every x. Таким образом,

(+1) . (-1) === id

Если вы знакомы с доказательством по индукции (proof by induction), попробуйте в качестве упражнения доказать первые три утверждения. Индукционные доказательства не входят в этот курс, поэтому незнание индукции не помешает дальнейшему обучению.

Из фактов 1-4 следует, что

    map (+1) . reverse . map (-1)
=== map (+1) . (reverse . map (-1))    -- By associativity of (.)
=== map (+1) . (map (-1) . reverse)    -- By fact 3
=== (map (+1) . map (-1)) . reverse    -- By associativity of (.)
=== map ((+1) . (-1)) . reverse        -- By fact 2
=== map id . reverse                   -- By fact 4
=== id . reverse                       -- By fact 1
=== reverse                            -- By the definition of id

Этот курс не будет вдаваться в подробности о доказательстве вещей о программах, но хорошо знать, что чисто функциональное программирование очень совместимо с таким анализом. [+]

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

Преимущества лени лучше всего демонстрируют некоторые примеры с участием infinite lists. Начнем с repeat 1, который генерирует бесконечный список 1. Если мы попытаемся сказать GHCi напечатать значение repeat 1, он просто будет продолжать печатать 1 вечно, пока мы не прервем его с помощью Control-C:

Prelude> repeat 1
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
^C

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

Prelude> take 10 $ repeat 1
[1,1,1,1,1,1,1,1,1,1]
Prelude> take 20 $ repeat 1
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
Prelude> repeat 1 !! 13337
1

Бесконечный список, который просто повторяет один элемент, иногда может быть необходим, но это бессмысленно. Рассмотрим несколько более полезных бесконечных списков. Вы можете использовать синтаксис [n..] для создания бесконечного списка чисел, начиная с n:

Prelude> take 20 [0..]
[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19]
Prelude> take 10 . map (2^) $ [0..]
[1,2,4,8,16,32,64,128,256,512]

Функция cycle повторяет элементы из данного списка снова и снова. Это может быть полезно при работе с вращениями или циклами.

Prelude> take 21 $ cycle "asdf"
"asdfasdfasdfasdfasdfa"
Prelude> take 4 . map (take 4) . tails $ cycle "asdf"
["asdf","sdfa","dfas","fasd"]

10.3.1 Пример: номера транзакций

В качестве более конкретного примера того, как cycle полезен, давайте рассмотрим вычисление контрольной цифры финских номеров транзакций банковских переводов (viitenumero). Номер транзакции состоит из произвольного количества цифр и одной контрольной цифры в конце. Для проверки остальные цифры справа налево умножают поочерёдно на 7, 3, 1, 7, 3, 1 и так далее, после чего складывают произведения. Если сумма вместе с контрольной цифрой делится на 10, номер считается корректным.

Вот конкретный пример. 116127 является действительным номером транзакции. Вычисления идут так:

digits:       1  1  6  1  2
              *  *  *  *  *
multipliers:  3  7  1  3  7
              3+ 7+ 6+ 3+14 = 33
check digit is 7, 33+7=40 is divisible by 10, valid

Вот код Haskell для проверки номера транзакции. Обратите внимание, как мы используем бесконечный список cycle [7,3,1] для множителей.

viitenumeroCheck :: [Int] -> Bool
viitenumeroCheck allDigits = mod (checksum+checkDigit) 10 == 0
  where (checkDigit:digits) = reverse allDigits
        multipliers = cycle [7,3,1]
        checksum = sum $ zipWith (*) multipliers digits
viitenumeroCheck [1,1,6,1,2,7]  ==> True
viitenumeroCheck [1,1,6,1,2,8]  ==> False

10.3.2 Пример: возведение в степень

Наконец, вот как вы найдете первую мощность 3, которая больше 100.

Prelude> head . filter (>100) $ map (3^) [0..]
243

Давайте рассмотрим, как это работает шаг за шагом. Обратите внимание, как карта и фильтр обрабатывают список лениво, по одному элементу за раз. Это похоже на то, как generators или iterators работают на таких языках, как Python или Java.

    head (filter (>100) (map (3^) [0..]))
==> head (filter (>100) (map (3^) (0:[1..])))   -- evaluate first element of the lazy list
==> head (filter (>100) (1 : map (3^) [1..]))   -- map processes the element
==> head (filter (>100) (map (3^) [1..]))       -- filter drops the element
==> head (filter (>100) (map (3^) (1:[2..])))   -- evaluate second element of the lazy list
==> head (filter (>100) (3 : map (3^) [2..]))   -- map processes the element
==> head (filter (>100) (map (3^) [2..]))       -- filter drops the element
-- let's take bigger steps now
==> head (filter (>100) (9 : map (3^) [3..]))   -- map processes, filter will drop
==> head (filter (>100) (27 : map (3^) [4..]))  -- map processes, filter will drop
==> head (filter (>100) (81 : map (3^) [5..]))  -- map processes, filter will drop
==> head (filter (>100) (243 : map (3^) [6..])) -- map processes
==> head (243 : filter (>100) (map (3^) [6..])) -- filter lets the value through
==> 243                                         -- head returns the result

10.4 Как работает Haskell?

Лень, вероятно, будет чувствовать себя немного волшебным для вас прямо сейчас. Вы можете задаться вопросом, как это можно реализовать. Оценка Haskell удивительно проста, она просто отличается от того, к чему вы привыкли. Давайте покопаемся.

В большинстве других языков программирования (таких как Java, C или Python) оценка продолжается снаружи. Аргументы функций оцениваются перед функцией.

Оценка Haskell происходит снаружи, а не снаружи. Определение внешней функции в выражении применяется без оценки каких-либо аргументов. Вот конкретный пример с игрушечными функциями f и g:

g :: Int -> Int -> Int
g x y = y+1
f :: Int -> Int -> Int -> Int
f a b c = g (a*1000) c

Внутренняя (нормальная) оценка:

f 1 (1234*1234) 2
  -- evaluate arguments to f
  ==> f 1 1522756 2
  -- evaluate f
  ==> g (1*1000) 2
  -- evaluate arguments to g
  ==> g 1000 2
  -- evaluate g
  ==> 2+1
  ==> 3

Хаскелл вне оценки:

f 1 (1234*1234) 2
  -- evaluate f without evaluating arguments
  ==> g (1*1000) 2
  -- evaluate g without evaluating arguments
  ==> 2+1
  ==> 3

Обратите внимание, что неиспользованные расчеты 1234*1234 и 1*1000 не были оценены. Вот почему лень часто бывает полезной.

10.4.1 Сопоставление с образцом запускает вычисления

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

not True = False
not False = True
map f [] = []
map f (x:xs) = f x : map f xs
length [] = 0
length (x:xs) = 1+length xs

Вот внутренняя оценка выражения:

length (map not (True:False:[]))
  ==> length (not True : not False : [])  -- evaluate call to map
  ==> length (False:True:[])              -- evaluate calls to not
  ==> 2

Вот как проходит оценка в Хаскелле. Обратите внимание, что это не строго внешне, так как иногда нам нужно оценить внутренние аргументы, чтобы знать, какой шаблон соответствует.

length (map not (True:False:[]))
  -- We can't evaluate length since we don't know which equation of length applies,
  -- so we look at length's argument. We can apply the second equation of map, so we do.
  ==> length (not True : map not (False:[]))
  -- Now the argument of length has a (:) we can pattern match on, so we apply the
  -- second equation of length
  ==> 1 + length (map not (False:[]))
  -- The outermost function is now +, but it can't do anything unless both arguments
  -- are numbers. So we need to evaluate length. In order to pick an equation, we need
  -- to evaluate the argument of length again. We apply the second equation of map
  ==> 1 + length (not False : map not ([]))
  -- Now we can apply the second equation of length again.
  ==> 1 + (1 + length (map not []))
  -- The outermost + needs a number to be evaluated. The second + also needs a number.
  -- We need to evaluate length again, which means we need to pick an equation for length,
  -- which means we need to evaluate its argument. This time it is the first equation for
  -- map that applies.
  ==> 1 + (1 + length [])
  -- Now we can apply the first equation for length
  ==> 1 + (1 + 0)
  -- The outermost + still can't be evaluated, but the inner one can
  ==> 1 + 1
  -- Finally we evaluate the outer +
  ==> 2

Обратите внимание, что нам не нужно было оценивать какие-либо приложения not.

Введём несколько терминов. Говорят, что сопоставление с образцом форсирует (forces) вычисление. Haskell вычисляет значение до слабой головной нормальной формы (weak head normal form, WHNF). По сути, WHNF — это значение, с которым уже можно сопоставить образец. Выражение находится в WHNF, если его нельзя вычислить дальше на верхнем уровне. Отсюда следует:

  • является постоянной, например: 1
  • Имеет конструктор на высшем уровне, например: False, Just (1+1), 0:filter f xs
  • является функцией, например: (\x -> 1+x)

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

В предыдущем примере мы не могли выбрать уравнение для length в length (map not (False:[])). Аргумент (map not ...) отсутствует в WHNF, поэтому его нельзя сопоставить с шаблоном. Поэтому мы должны его оценить. Когда мы применяем второе уравнение map, мы получаем length (not False : map not []), и теперь аргумент в пользу длины находится в WHNF, поскольку на верхнем уровне находится конструктор (:). Это немного более очевидно, если мы перейдем от инфикса к префиксной записи и напишем аргумент length как (:) (not False) (map not []).

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

Вместо того, чтобы заставлять, некоторые источники говорят о строгость Например, мы можем сказать, что (+) это Строго в обоих аргументах.

10.4.2 Несколько слов о разделении вычислений (sharing)

Есть еще одна вещь об оценке Хаскелла. Каждый раз, когда вы даете значение name, он получает shared. Это означает, что каждое появление имени указывает на одно и то же (потенциально неоцененное) выражение. Когда выражение оценивается, все вхождения имени видят результат.

Давайте рассмотрим очень простой пример.

square x = x*x

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

square (2+2)
  ==> (2+2) * (2+2)   -- definition of square
  ==>   4   * (2+2)   -- (*) forces left argument
  ==>   4   *   4     -- (*) forces right argument
  ==>      16         -- definition of (*)

На самом деле выражение 2+2, которому дали имя x, вычисляется лишь один раз. Затем полученный результат используется в обоих вхождениях x внутри square. Ниже показано корректное вычисление — сначала текстом, затем графически. Теперь вместо дерева выражений получается граф выражений, поэтому модель вычислений Haskell иногда называют уменьшение графа.

square (2+2)
  ==> (2+2) * (2+2)
  ==>   4   *   4
  ==>      16

В качестве другого примера рассмотрим функцию f ниже и ее оценку.

f :: Int -> Int
f i = if i>10 then 10 else i
                  _______shared________
                 |                     |
f (1+1) ==> if (1+1)>10 then 10 else (1+1)
        ==> if 2>10 then 10 else 2
        ==> if False then 10 else 2
        ==> 2

Haskell не вычисляет 1+1 дважды, потому что он был назван, и имя использовалось дважды. Мы можем противопоставить это другой функции, которая принимает два аргумента:

g :: Int -> Int -> Int
g i j = if i>10 then 10 else j
                        ______no sharing_____
                       |                     |
g (1+1) (1+1) ==> if (1+1)>10 then 10 else (1+1)
              ==> if 2>10 then 10 else (1+1)
              ==> if False then 10 else (1+1)
              ==> (1+1)
              ==> 2

Здесь у нас есть два разных имени для эквивалентных выражений, и Хаскелл волшебным образом не делится ими. Автоматически совместное использование эквивалентных выражений называется оптимизацией Common Subexpression Elimination (CSE). Узнать немного больше CSE и Haskell можно здесь.

Вы можете назвать вещи через

  • Функциональные аргументы
  • let ... in ...
  • where

В сочетании с ленью обмен означает, что Имя оценивается не более одного раза.

10.4.3 Дополнительные примеры

Ниже вы найдете немного надуманное рекурсивное определение функции even. Он будет иллюстрировать концепции принуждения и обмена.

not :: Bool -> Bool
not True = False
not False = True

(||) :: Bool -> Bool -> Bool
True || _ = True
_    || x = x

even :: Int -> Bool
even x  =  (x == 0)  ||  not (even (x-1))

Сначала заметим, что || форсирует левый аргумент, но не правый. Иными словами, || строг по левому аргументу (strict in its left argument). Чтобы выбрать подходящее уравнение, достаточно вычислить левый аргумент ||. Следовательно, even форсирует свой первый аргумент:

  • || вытесняет x==0
  • x==0 вытесняет x

Теперь давайте оценим выражение even 2 по WHNF.

even 2
==> 2 == 0  ||  not (even (2-1))                    -- apply definition of even
==> False   ||  not (even (2-1))                    -- || forces its first argument
==> not (even (2-1))                                -- second equation of ||
==> not ((2-1) == 0 || not (even ((2-1)-1)))        -- not forces its argument: apply definition of even
==> not (  1   == 0 || not (even (  1  -1)))        -- note sharing!
==> not (  False    || not (even (1-1)))
==> not (not (even (1-1)))
==> not (not ((1-1) == 0 || not (even ((1-1)-1))))
==> not (not (  0   == 0 || not (even (  0  -1))))  -- (sharing)
==> not (not (   True    || not (even (0-1))))
==> not (not True)
==> not False
==> True

Обратите внимание, что с этим альтернативным определением even не сработало бы. Вы можете сказать почему?

even' x =  not (even' (x-1))  ||  x == 0

Теперь мы действительно можем понять, что происходит в бесконечном списке примеров. Давайте используем эти определения:

head (x:_) = x
head [] = -1

filter p [] = []
filter p (x:xs) = if p x
                  then x : filter p xs
                  else filter p xs

map f [] = []
map f (x:xs) = f x : map f xs

-- [0..] is syntax sugar for enumFrom 0
enumFrom n = n : enumFrom (n+1)

А вот и мы:

    head (filter (>100) (map (3^) [0..]))
=== head (filter (>100) (map (3^) (enumFrom 0)))
-- head forces filter, which forces map, which forces enumFrom. We apply the definition of enumFrom.
==> head (filter (>100) (map (3^) (0:[1..])))
-- head forces filter, which forces map. We apply the second equation of map.
==> head (filter (>100) ((3^0) : map (3^) [1..]))
-- head forces filter. We apply the second equation of filter
==> head (if ((3^0)>100)
          then (3^0) : filter (>100) (map (3^) [1..])
          else filter (>100) (map (3^) [1..]))
-- head forces if, if forces >, > forces ^. Note sharing!
==> head (if (1>100)
          then 1 : filter (>100) (map (3^) [1..])
          else filter (>100) (map (3^) [1..]))
-- head forces if, if forces >
==> head (if False
          then 1 : filter (>100) (map (3^) [1..])
          else filter (>100) (map (3^) [1..]))
-- apply definition of if
==> head (filter (>100) (map (3^) [1..]))
-- let's take slightly bigger steps now
==> head (filter (>100) (map (3^) (1:[2..])))
==> head (filter (>100) ((3^1) : map (3^) [2..]))
==> head (filter (>100) (3 : map (3^) [2..]))
==> head (filter (>100) (map (3^) [2..]))
-- and even bigger steps now
==> head (filter (>100) (9 : map (3^) [3..]))
==> head (filter (>100) (27 : map (3^) [4..]))
==> head (filter (>100) (81 : map (3^) [5..]))
==> head (filter (>100) (243 : map (3^) [6..]))
==> head (243 : filter (>100) (map (3^) [6..]))
==> 243

Вуу.

10.5 Работа с бесконечными списками

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

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

everySecond :: [a] -> [a]
everySecond [] = []
everySecond (x:y:xs) = x : everySecond xs
take 10 (everySecond [0..])  ==>  [0,2,4,6,8,10,12,14,16,18]

Хорошая эвристика для написания функций, которые хорошо работают с бесконечными списками, заключается в следующем: может ли head результата быть оценено дешево? Вот два примера функций, которые не работают с бесконечными входами. В случае с mapTailRecursive проблема заключается в том, что перед тем, как войти в WHNF, он должен обработать весь вход. В случае с myDrop проблема заключается в том, что он использует функцию length, которая не работает для бесконечных списков.

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs

mapTailRecursive :: (a -> b) -> [a] -> [b]
mapTailRecursive f xs = go xs []
    where go (x:xs) res = go xs (res++[f x])
          go []     res = res
head (map succ [0..]) ==> head (succ 0 : map succ [1..]) ==> succ 0 ==> 1
head (mapTailRecursive succ [0..])
  ==> head (go [0..] [])
  ==> head (go [1..] ([]++[succ 0]))
  ==> head (go [2..] ([]++[succ 0]++[succ 1]))
  ==> head (go [3..] ([]++[succ 0]++[succ 1]++[succ 2]))
  --  never terminates
drop :: Int -> [a] -> [a]
drop 0 xs = xs
drop _ [] = []
drop n (x:xs) = drop (n-1) xs

myDrop :: Int -> [a] -> [a]
myDrop 0 xs = xs
myDrop n xs = if n > length xs then [] else myDrop (n-1) (tail xs)
head (drop 2 [0..]) ==> head (drop 1 [1..]) ==> head (drop 0 [2..]) ==> head [2..] ==> 2
head (myDrop 2 [0..])
  ==> head (if n > length [0..] then [] else myDrop (n-1) (tail [0..]))
  ==> head (if n > 1+length [1..] then [] else myDrop (n-1) (tail [0..]))
  ==> head (if n > 1+1+length [2..] then [] else myDrop (n-1) (tail [0..]))
  ==> head (if n > 1+1+1+length [3..] then [] else myDrop (n-1) (tail [0..]))
  --  never terminates

Практически все функции списка в стандартной библиотеке написаны в таком виде, например:

head (takeWhile (>=0) [0..]) ==> 0
head (concat (repeat [1,2,3])) ==> 1
head (zip [0..] [2..]) ==> (0,2)
head (filter even [3..]) ==> 4

10.6 Интерлюдия: добавляем строгость

Помните foldr из части 1? Рассмотрим родственную функцию foldl. Ниже приведено определение foldl для списков; на самом деле она входит в класс типов Foldable и работает также с другими структурами. foldr обрабатывает список справа налево, а foldl — слева направо. Точнее, foldr правоассоциативна, тогда как foldl левоассоциативна. Сравните:

foldr (+) 0 [1,2,3]  ==>  1+(2+(3+0))
foldl (+) 0 [1,2,3]  ==>  ((0+1)+2)+3

Вот определения foldl и foldr:

foldl :: (a -> b -> a) -> a -> [b] -> a
foldl f z [] = z
foldl f z (x:xs) = foldl f (f z x) xs
foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f y []     = y
foldr f y (x:xs) = f x (foldr f y xs)

Как foldr f y (x:xs) ==> f x (foldr f y xs), он позволяет ленивым оценкам сосредоточиться на f на втором этапе. Таким образом, foldr хорошо работает с ленивыми или короткозамыкающими операциями:

    foldr (&&) True [False,False,False]
==> False && (foldr (&&) True [False,False])
==> False
    head (foldr (++) [] ["Hello","World","lorem","ipsum"])
==> head ("Hello" ++ (foldr (++) [] ["World","lorem","ipsum"]))
==> head ('H':("ello" ++ (foldr (++) [] ["World","lorem","ipsum"])))
==> 'H'

Однако foldl необходимо обработать весь список, чтобы получить значение WHNF. Причина в том, что foldl остается в самой левой позиции до тех пор, пока его аргументы в списке остаются пустыми. Это делает foldl приоритетом для ленивых оценок. Только после того, как список становится пустым, оценка переходит к упрощению сложенных значений.

    foldl (&&) True [False,False,False]
==> foldl (&&) (True&&False) [False,False]
==> foldl (&&) ((True&&False)&&False) [False]
==> foldl (&&) (((True&&False)&&False)&&False) []
==> ((True&&False)&&False)&&False
==> (    False    &&False)&&False
==>              False    &&False
==>                      False
    head (foldl (++) [] ["Hello","World","lorem","ipsum"])
==> head (foldl (++) ([]++"Hello") ["World","lorem","ipsum"])
==> head (foldl (++) (([]++"Hello")++"World") ["lorem","ipsum"])
==> head (foldl (++) ((([]++"Hello")++"World")++"lorem") ["ipsum"])
==> head (foldl (++) (((([]++"Hello")++"World")++"lorem")++"ipsum") [])
==> head (((([]++"Hello")++"World")++"lorem")++"ipsum")
-- head forces the last ++, which forces the next-to-last ++, and so on
==> head ((("Hello"++"World")++"lorem")++"ipsum")
-- same happens again
==> head ((('H':("ello"++"World"))++"lorem")++"ipsum")
-- for clarity, let's drop the "ello"++"World" expression which isn't needed
==> head ((('H':__)++"lorem")++"ipsum")
-- now the next-to-last ++ can operate
==> head (('H':(__++"lorem"))++"ipsum")
-- let's drop the __++"lorem" expression
==> head (('H':__)++"ipsum")
-- now the last ++ can operate
==> head ('H':(__++"ipsum"))
==> 'H'

Так зачем вообще использовать foldl? Вернемся к нашему первому примеру. Теперь, поскольку + является строгой операцией, оба типа сгиба должны создавать выражение с большим количеством +. Реализация Haskell должна отслеживать это выражение в памяти, поэтому такая проблема называется space leak.

    foldr (+) 0 [1,2,3]
==> 1 + foldr (+) 0 [2,3]
==> 1 + (2 + foldr (+) 0 [3])
==> 1 + (2 + (3 + foldr (+) 0 []))
==> 1 + (2 + (3 + 0))
==> 1 + (2 + 3)
==> 1 + 5
==> 6
    foldl (+) 0 [1,2,3]
==> foldl (+) (0+1) [2,3]
==> foldl (+) ((0+1)+2) [3]
==> foldl (+) (((0+1)+2)+3) []
==> ((0+1)+2)+3
==> (  1  +2)+3
==>       3  +3
==>          6

Теперь давайте посмотрим, что происходит, когда мы используем foldl', версию foldl, которая выдвигает свой второй аргумент!

    foldl' (+) 0 [1,2,3]
==> foldl' (+) (0+1) [2,3]
-- force second argument
==> foldl' (+) 1 [2,3]
==> foldl' (+) (1+2) [3]
-- force second argument
==> foldl' (+) 3 [3]
==> foldl' (+) (3+3) []
-- force second argument
==> foldl' (+) 6 []
==> 6

Теперь работа выполняется постепенно при сканировании списка. Никакой утечки! Иногда слишком много лени может вызвать утечку пространства, и немного строгости может исправить их.

Вы можете найти foldl' в Data.List Модуль, и он работает именно так. Но как можно реализовать foldl' Мы точно знаем, как это сделать для определенного типа, скажем. Int Мы просто добавляем совпадение шаблонов на второй аргумент, который не изменяет семантику функции.

foldl'Int :: (Int -> Int -> Int) -> Int -> [Int] -> Int
foldl'Int f z [] = z
foldl'Int f 0 (x:xs) = foldl'Int f (f 0 x) xs
foldl'Int f z (x:xs) = foldl'Int f (f z x) xs
    foldl'Int (+) 0 [1,2,3]
==> foldl'Int (+) (0+1) [2,3]
-- to be able to pick between the second and third equations, (0+1) is forced
==> foldl'Int (+) 1 [2,3]
-- the third equation applies
==> foldl'Int (+) (1+2) [3]
-- again, we need to pick between the second and third equations
==> foldl'Int (+) 3 [3]
==> foldl'Int (+) (3+3) []
==> 3+3
==> 6

Для написания общей реализации foldl' необходимо ввести новую встроенную функцию seq. Звонок seq a b оценивает b, но вынуждает a войти в WHNF. Вот несколько примеров использования seq в GHCi. Чтобы продемонстрировать то, что оценивается, мы используем специальное значение undefined, которое вызывает ошибку, если что-то пытается оценить его в WHNF.

Prelude> seq (not True) 3
3
Prelude> seq undefined 3
*** Exception: Prelude.undefined
Prelude> (seq (not True) 3) + 7
10
Prelude> (seq undefined 3) + 7
*** Exception: Prelude.undefined
Prelude> let f x = f x in seq (f 3) 3
-- ...infinite recursion

В качестве примера использования seq в функции приведена версия head, которая не работает для бесконечных списков (поскольку она оценивает последний элемент списка):

strictHead :: [a] -> a
strictHead xs = seq (last xs) (head xs)

Давайте поиграем с ним в GHCi:

Prelude> head [1,2,3]
1
Prelude> strictHead [1,2,3]
1
Prelude> head (1:2:undefined)
1
Prelude> strictHead (1:2:undefined)
*** Exception: Prelude.undefined
Prelude> head [1..]
1
Prelude> strictHead [1..]
-- ...infinite recursion

Наконец, вот определение для foldl'. Обратите внимание, как нам нужно ввести совместное использование новой переменной, z', чтобы seq мог оценить новое значение, а затем использовать его в рекурсивном вызове. Новое определение также используется в более подробной оценке foldl' (+) 0 [1,2,3] ниже.

foldl' :: (a -> b -> a) -> a -> [b] -> a
foldl' f z [] = z
foldl' f z (x:xs) = let z' = f z x
                    in seq z' (foldl' f z' xs)
    foldl' (+) 0 [1,2,3]
==> seq (0+1) (foldl' (+) (0+1) [2,3])  -- seq forces first argument
          |                 |
          +-----sharing-----'
          |                 |
==> seq   1   (foldl' (+)   1   [2,3])  -- first argument to seq in WHNF, seq disappears
==> foldl' (+) 1 [2,3]
==> seq (1+2) (foldl' (+) (1+2) [3])
==> seq   3   (foldl' (+)   3   [3])
==> foldl' (+)   3   [3]
==> seq (3+3) (foldl' (+) (3+3) [])
==> seq   6   (foldl' (+)   6   [])
==> foldl' (+)   6   []
==> 6

Мы не будем углубляться в эту тему на этом курсе, но важно, чтобы вы знали, что seq существует. Подробнее об seq можно узнать на Haskell Wiki, а о том, когда нужна строгость, — в книге Real World Haskell. Часто лучше использовать шаблоны bang вместо seq, как обсуждается FPComplete и Real World Haskell.

10.7 Объявления newtype

Вспомните лекцию 7. Иногда нужны коробочные типы. Есть специальное ключевое слово newtype Можно использовать вместо data Когда нужен тип коробки. newtype Мы ожидаем одного строителя, с одним полем. Например,

newtype Money = Cents Int

Тем не менее, следующие не будут работать, вам нужно data:

-- the compiler won't accept these!
newtype Currency = Dollars Int | Euros Int
newtype Money = Money Int Int

Так в чем разница? С точки зрения написания кода, ничего. Вы работаете с newtype точно так же, как с data. Однако макет памяти отличается. С помощью data вводится косвенный слой (конструктор), а с помощью newtype – нет. Опосредование для data необходимо для поддержки нескольких конструкторов и нескольких полей. Иллюстрация:

code:                                 memory:

data Money = Cents Int                x --> Cents --> 100
x = Cents 100


newtype Money = Cents Int             x --> 100
x = Cents 100

Эта разница имеет много последствий. Прежде всего, newtype более эффективен: при компиляции тип можно сказать «исчезает». Тип все еще проверяется, поэтому вы получаете безопасность типа без какого-либо воздействия на производительность. Во-вторых, новинками являются strict. Конкретно это означает, что Money x находится в слабой нормальной форме головы, только если x находится в WHNF. Это можно увидеть в GHCi:

-- if we use data, Cents undefined is in WHNF
Prelude> data Money = Cents Int
Prelude> seq (Cents undefined) True
True
-- if we use newtype, Cents undefined isn't in WHNF, and trying
-- to make it so trips up in undefined
Prelude> newtype Money = Cents Int
Prelude> seq (Cents undefined) True
*** Exception: Prelude.undefined

Когда следует использовать newtype? В общем, лучше всего использовать newtype, когда у вас есть однопользовательский одноконструкторный тип данных. Однако ничто не пойдет катастрофически неправильно, если вы всегда используете data. Модель newtype также часто используется, когда вам нужно определить другой экземпляр класса типа для типа. Вот пример, который определяет тип числа с перевернутым заказом.

newtype Inverted = Inverted Int
  deriving (Show, Eq)

instance Ord Inverted where
  compare (Inverted i) (Inverted j) = compare j i
Prelude Data.List> sort [1,2,3]
[1,2,3]
Prelude Data.List> sort [Inverted 1,Inverted 2,Inverted 3]
[Inverted 3,Inverted 2,Inverted 1]

10.8 Что-то интересное: завязываем узел (Tying the Knot)

Теперь, разобравшись с совместным использованием и копированием пути, мы можем создать собственную циклическую структуру данных. Помните примеры с функцией cycle?

Prelude> take 21 $ cycle "asdf"
"asdfasdfasdfasdfasdfa"

Вот как это выглядит в памяти:

Ранее сообщалось, что формы данных Haskell направляли графы в память. Это пример направленного графа с циклом.

Как можно определить такие структуры? Мы просто даем значение имени и ссылаемся на это имя в самой стоимости. То есть значение recursive или self-referential. Этот трюк известен как tying knot. Простой пример:

  code                     memory

let xs = 1:2:xs      xs -> (1:) -> (2:) -+
 in xs                      ^            |
                            +------------+

Обратите внимание, как мы используем название xs в определении xs. Когда мы делаем такое рекурсивное определение, совместное использование вызывает его превращение в циклическую структуру памяти.

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

data Room = Room String [(String,Room)]

describe :: Room -> String
describe (Room s _) = s

move :: Room -> String -> Maybe Room
move (Room _ directions) direction = lookup direction directions

world :: Room
world = meadow
  where
    meadow = Room "It's a flowery meadow next to a cliff." [("Stay",meadow),("Enter cave",cave)]
    cave = Room "You are in a cave" [("Exit",meadow),("Go deeper",tunnel)]
    tunnel = Room "This is a very dark tunnel. It seems you can either go left or right."
                  [("Go back",cave),("Go left",pit),("Go right",treasure)]
    pit = Room "You fall into a pit. There is no way out." []
    treasure = Room "A green light from a terminal fills the room. The terminal says <<loop>>."
                    [("Go back",tunnel)]

play :: Room -> [String] -> [String]
play room [] = [describe room]
play room (d:ds) = case move room d of Nothing -> [describe room]
                                       Just r -> describe room : play r ds
Prelude> play world ["Stay","Enter cave","Go deeper","Go back","Go deeper","Go right"]
["It's a flowery meadow next to a cliff.",
 "It's a flowery meadow next to a cliff.",
 "You are in a cave",
 "This is a very dark tunnel. It seems you can either go left or right.",
 "You are in a cave",
 "This is a very dark tunnel. It seems you can either go left or right.",
 "A green light from a computer terminal floods the room. The terminal says <<loop>>."]

Вот как выглядит world игры в памяти:

               ,-----------------,
               v                 |
         +-----------------------|-----------------+
meadow-->|Room "It's..." ["Stay" o, "Enter cave" o]|
         +---------------------------------------|-+
                                    ^            v
         +--------------------------|----------------+
cave---->|Room "You are..." ["Exit" o, "Go deeper" o]|
         +-----------------------------------------|-+
                                       ^           v
         +-----------------------------|----------------------------+
tunnel-->|Room "This is..." ["Go back" o, "Go left" o, "Go right" o]|<--------,
         +------------------------------------------|-------------|-+         |
                                                    |             |           |
                     ,------------------------------'             |           |
                     v                                            v           |
         +---------------------+                +-----------------------------|-+
pit----->|Room "You fall..." []|    treasure--->|Room "A green..." ["Go back" o]|
         +---------------------+                +-------------------------------+

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

10.9 Что-то интересное: Debug.Trace

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

Мы можем использовать функцию trace :: String -> a -> a из модуля Debug.Trace, чтобы заглянуть в оценку Haskell. Выражение trace "message" x такое же, как и x, но печатает message при его оценке. Мы можем использовать trace, чтобы засвидетельствовать лень оператора ||:

Prelude> import Debug.Trace
Prelude Debug.Trace> trace "a" True
a
True
Prelude Debug.Trace> trace "a" False || trace "b" True
a
b
True
Prelude Debug.Trace> trace "a" True || trace "b" True
a
True

Мы также можем посмотреть, когда оцениваются элементы списка. Обратите внимание, что length не нуждается в оценке элементов списка, и sum должен оценить их все. (Чтобы быть точным, head xs фактически не оценивает первый элемент xs, но возвращает его в GHCi, который оценивает его, чтобы показать его.)

Prelude Debug.Trace> head [trace "first" 1, trace "second" 2, trace "third" 3]
first
1
Prelude Debug.Trace> last [trace "first" 1, trace "second" 2, trace "third" 3]
third
3
Prelude Debug.Trace> length [trace "first" 1, trace "second" 2, trace "third" 3]
3
Prelude Debug.Trace> sum [trace "first" 1, trace "second" 2, trace "third" 3]
third
second
first
6

Модуль Debug.Trace предлагает и полезные варианты trace. Особенно удобна функция traceShowId x: она печатает show x, а результатом оставляет x. Проверим вычисление предыдущего примера с head, filter и map с помощью traceShowId. Хотя traceShowId отображается на бесконечный список [0..], фактически вычисляются только шесть значений. Последнее число 243 — возвращаемый результат, а не сообщение трассировки.

Prelude Debug.Trace> head (filter (>100) (map (\x -> traceShowId (3^x)) [0..]))
1
3
9
27
81
243
243

Debug.Trace особенно полезен, когда у вас есть бесконечная рекурсионная ошибка. Вот пример:

-- computes sums like 7+5+3+1
sumEverySecond :: Int -> Int
sumEverySecond 0 = 0
sumEverySecond n = n + sumEverySecond (n-2)
sumEverySecond 6 ==> 12
sumEverySecond 7 ==> doesn't terminate

Мы можем отладить это, добавив trace, чтобы обернуть весь рекурсивный случай.

sumEverySecond :: Int -> Int
sumEverySecond 0 = 0
sumEverySecond n = trace ("sumEverySecond "++show n) (n + sumEverySecond (n-2))
Prelude Debug.Trace> sumEverySecond 6
sumEverySecond 6
sumEverySecond 4
sumEverySecond 2
12
Prelude Debug.Trace> sumEverySecond 7
sumEverySecond 7
sumEverySecond 5
sumEverySecond 3
sumEverySecond 1
sumEverySecond -1
sumEverySecond -3
sumEverySecond -5
-- and so on

Ха! Проблема в том, что нашего базового случая рекурсии sumEverySecond 0 недостаточно, чтобы остановить рекурсию.

Наконец, слово осторожности. Использование trace, и особенно traceShowId, может вызвать вещи, которые иначе не были бы оценены, чтобы получить оценку. Например:

Prelude Debug.Trace> traceHead xs = head (traceShowId xs)
Prelude Debug.Trace> traceHead [0..]
-- never terminates since it's trying to show an infinite list

Поэтому не стесняйтесь использовать Debug.Trace при работе над Упражнениями, но постарайтесь оставить trace звонки из ваших окончательных ответов. Некоторые наборы упражнений проверяют ваш импорт и запрещают Debug.Trace.

В следующей лекции мы увидим более принципиальный способ борьбы с побочными эффектами!

10.10 Викторина

Какое из этих утверждений верно?

  1. reverse . reverse . reverse === reverse
  2. reverse . reverse === reverse
  3. reverse . id === id

Какой из них является бесконечным списком, который начинается с [0,1,2,1,2,1,2...]?

  1. cycle [0,1,2]
  2. 0:repeat [1,2]
  3. 0:cycle [1,2]
  4. 0:[1,2..]

Каков следующий шаг при оценке этого выражения?

head (map not (True:False:[]))
  1. head (False : True : [])
  2. head (not True)
  3. head (False : map not (False:[]))
  4. head (not True : map not (False:[]))

Какое из этих значений not в слабой нормальной форме головы?

  1. map
  2. f 1 : map f (2 : [])
  3. Just (not False)
  4. (\x -> x) True

Какое из этих утверждений о следующей функции верно?

f 0 x = 1+x
f _ x = 2+x
  1. f строго придерживается левого аргумента
  2. f строга в своих аргументах
  3. f приводит оба аргумента
  4. Ни один из вышеперечисленных

Работает ли эта функция с бесконечными списками? Почему?

f [] = []
f (x:xs) = x : map not xs
  1. Нет, потому что он включает в себя случай [], который так и не был достигнут.
  2. Нет, потому что он использует map, который оценивает весь список.
  3. Да, потому что он смотрит только на первый элемент списка, прежде чем произвести значение WHNF.
  4. Да, потому что это называется map, который работает с бесконечными списками.

Что насчет этого?

f xs = map (+(sum xs)) xs
  1. Нет, потому что он использует map, который оценивает весь список.
  2. Нет, потому что вычисление sum результата требует всего списка входных данных.
  3. Да, потому что он не включает случай []
  4. Да, потому что это называется map, который работает с бесконечными списками.

10.11 Упражнения

11 Лекция 11: RealWorld -> (a,RealWorld)

11.1 Содержание

  • Ио

11.2 Вас обманули!

На минуту забудьте всё сказанное о функциональном программировании и чистоте. Haskell — лучший императивный язык программирования в мире! Начнём:

questionnaire = do
  putStrLn "Write something!"
  s <- getLine
  putStrLn ("You wrote: "++s)
Prelude> questionnaire
Write something!
Haskell!
You wrote: Haskell!

Чтение ввода и запись вывода было достаточно легко. Мы также можем читать по сети. Вот полная программа Haskell, которая извлекает некоторые слова из URL-адреса с помощью HTTP и печатает их.

import Network.HTTP
import Control.Monad

main = do
  rsp <- simpleHTTP (getRequest "http://httpbin.org/base64/aGFza2VsbCBmb3IgZXZlcgo=")
  body <- getResponseBody rsp
  forM_ (words body) $ \w -> do
     putStr "word: "
     putStrLn w

Вы можете найти эту программу в репозитории курса как exercises/Examples/FetchWords.hs, и вы можете запустить его следующим образом:

$ cd exercises/Examples
$ stack runhaskell FetchWords.hs
word: haskell
word: for
word: ever

Что здесь происходит? Давайте посмотрим на типы:

Prelude> :t putStrLn
putStrLn :: String -> IO ()
Prelude> :t getLine
getLine :: IO String

Значение типа IO a — это операция (operation), которая производит значение типа a. Например, getLine — операция ввода-вывода, производящая строку. Тип () называется единичным типом (unit type), и его единственное значение — (). Он используется, когда операция ввода-вывода не возвращает содержательного результата, но выполняет побочные эффекты.

Сравнение с типами Java может помочь:

тип Haskell Тип Java
doIt :: IO () void doIt()
getSomething :: IO Int int getSomething()
force :: a -> b -> IO () void force(a arg0, b arg1)
mogrify :: c -> IO d d mogrify(c arg)

Операции ввода-вывода могут быть объединены в более крупные операции с использованием do-notation.

do operation
   operation arg
   variable <- operationThatReturnsStuff
   let var2 = expression
   operationThatProducesTheResult var2

11.2.1 Примеры

Полезные операции ввода-вывода можно найти в стандартных библиотечных модулях Prelude и System. IO

Вот операция ввода-вывода, которая запрашивает у пользователя строку и распечатывает длину строки.

query :: IO ()
query = do
  putStrLn "Write something!"                    -- run an operation, ignore produced value
  s <- getLine                                   -- run an operation, capture produced value
  let n = length s                               -- run a pure function
  putStrLn ("You wrote "++show n++" characters") -- run an operation, passing on the produced value
Prelude> query
Write something!
lorem ipsum
You wrote 11 characters

Значение, произведенное последней линией блока do, является значением, произведенным целым блоком. Обратите внимание, что askForALine имеет тот же тип, что и getLine, IO String:

askForALine :: IO String
askForALine = do
  putStrLn "Please give me a line"
  getLine

В дополнение к операциям ввода-вывода, таким как query, вы также можете запускать операции ввода-вывода, которые производят значения, такие как askForALine, в GHCi. Вы можете использовать <- для захвата результата операции в переменную, если хотите.

Prelude> askForALine
Please give me a line
this is a line
"this is a line"
Prelude> line <- askForALine
Please give me a line
this is a line
Prelude> :t line
line :: String
Prelude> line
"this is a line"

Если операции нужны параметры, можно определить функцию, возвращающую операцию. Обратите внимание: тип ask, как у обычной функции, содержит ->. Обычный синтаксис определения функции используется и для имени параметра question.

ask :: String -> IO String
ask question = do
  putStrLn question
  getLine
Prelude> ask "What is love?"
What is love?
Baby don't hurt me!
"Baby don't hurt me!"
Prelude> response <- ask "Who are you?"
Who are you?
The programmer.
Prelude> response
"The programmer."
Prelude> :t response
response :: String
Prelude> :t ask
ask :: String -> IO String
Prelude> :t ask "Who are you?"
ask "Who are you?" :: IO String

11.3 Тонкости return

Название функции Haskell return немного сбивает с толку. В других языках return — встроенное ключевое слово, а в Haskell это обычная функция. return :: a -> IO a принимает значение и превращает его в операцию, производящую это значение.

produceThree :: IO Int
produceThree = return 3

printThree :: IO ()
printThree = do
  three <- produceThree
  putStrLn (show three)

Это звучит не очень полезно, не так ли? В сочетании с нотацией это так. Здесь мы возвращаем boolean в зависимости от того, ответил ли пользователь Y или N:

yesNoQuestion :: String -> IO Bool
yesNoQuestion question = do
  putStrLn question
  s <- getLine
  return (s == "Y")
Prelude> yesNoQuestion "Fire the missiles?"
Fire the missiles?
Y
True
Prelude> answer <- yesNoQuestion "Are you sure?"
Are you sure?
N
Prelude> :t answer
answer :: Bool
Prelude> answer
False

Записка! Это означает, что возвращение Не прекращается исполнение Операция (в отличие от Java или C). Помните, что в do-блоках последняя строка определяет, какую стоимость производить. Это означает, что данная операция производит 2:

produceTwo :: IO Int
produceTwo = do return 1
                return 2
Prelude> produceTwo
2

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

produceThree = do putStrLn "1"   -- side effect, produces (), which is ignored
                  return    2    -- no side effect, produces 2, which is ignored
                  getLine        -- side effect, produces a String, which is ignored
                  return    3    -- no side effect, produces 3, which is passed on
Prelude> final <- produceThree
1
this line is ignored
Prelude> final
3

Также обратите внимание, что это одна и та же операция:

do ...
   x <- op
   return x
do ...
   op

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

return (f x : xs)
-- alternatively:
return $ f x : xs

11.4 do и типы

Давайте рассмотрим типизацию донотации более подробно. Do-блок строит значение типа IO <something>. Например, в

foo = do
  ...
  lastOp

lastOp должен быть типа IO X (для некоторых X). Типом foo также будет IO X. Рассмотрим следующий пример с параметрами:

bar x y = do
  ...
  lastOp arg

lastOp должен быть типа Y -> IO X (так что lastOp arg имеет тип IO X). Тип bar будет A -> B -> IO X (а внутри bar будут x :: A и y :: B).

Если вы используете return:

quux x = do
  ...
  return value

Функция quux будет иметь тип A -> IO B, где x :: A и value :: B.

Давайте посмотрим на типизацию <- дальше. Если op :: IO X и у вас есть var <- op, var будет иметь тип X. Мы видели это во многих примерах GHCi.

Последняя линия do не может быть foo <- bar. Это также не может быть let foo = bar. Последняя линия определяет, что производит вся операция, поэтому она должна быть операцией (например, return something).

Вот работающий пример:

alwaysFine :: IO Bool
alwaysFine = do
  putStrLn "What?" -- :: IO ()
  return 2         -- :: IO Int, produced value is discarded
  s <- getLine     -- getLine :: IO String, thus s :: String
  putStrLn s       -- putStrLn :: String -> IO (), thus putStrLn s :: IO ()
  let b = True     -- b :: Bool
  return b         -- :: IO Bool
                   -- Thus, alwaysFine :: IO Bool

Система типов не позволяет «сбежать» из IO. Хотя <- извлекает X из IO X, использовать <- можно только внутри do, а весь блок do снова имеет тип IO Y. Иными словами, коробку IO можно ненадолго открыть, но затем необходимо в неё вернуться. Что происходит в IO, остаётся в IO.

Позже мы поговорим о том, что это значит. На данный момент достаточно знать, что если у вас есть функция с типом не-IO, например, myFunction :: Int -> [String] -> String, внутри нее не может происходить IO. Это чистая функция.

11.5 Управляющие конструкции

Для следующих примеров нам понадобятся две новые операции.

print :: Show a => a -> IO ()   -- print a value using the show function
readLn :: Read a => IO a        -- get a line and convert it to a value using the read function

Обычные инструменты рекурсии, защитные выражения (guards) и if-then-else также работают в мире IO. Вот операция IO, которая определяется с помощью охранника:

printDescription :: Int -> IO ()
printDescription n
  | even n    = putStrLn "even"
  | n==3      = putStrLn "three"
  | otherwise = print n
Prelude> printDescription 2
even
Prelude> printDescription 3
three
Prelude> printDescription 5
5

Вот операция, которая печатает все числа в списке с использованием рекурсии и сопоставления шаблонов:

printList :: [Int] -> IO ()
printList [] = return () -- do nothing
printList (x:xs) = do print x
                      printList xs -- recursion
Prelude> printList [1,2,3]
1
2
3

Вот два немного более сложных примера рекурсивных операций ввода-вывода. Они используют значение, создаваемое рекурсивным вызовом. Операция readAndSum n считывает номера n от пользователя и печатает их сумму. Операция ask questions показывает пользователю каждую строку в questions, считывает ответ и возвращает список всех ответов.

readAndSum :: Int -> IO Int
readAndSum 0 = return 0
readAndSum n = do
  i <- readLn            -- read one number
  s <- readAndSum (n-1)  -- recursion: read and sum rest of numbers
  return (i+s)           -- produce result
Prelude> s <- readAndSum 3
2
4
5
Prelude> s
11
ask :: [String] -> IO [String]
ask [] = return []
ask (question:questions) = do
  putStr question
  putStrLn "?"
  answer <- getLine         -- get one answer
  answers <- ask questions  -- recursion: get rest of answers
  return (answer:answers)   -- produce result
Prelude> replies <- ask ["What is your name","How old are you"]
What is your name?
Yog-Sothoth
How old are you?
The question is meaningless
Prelude> replies
["Yog-Sothoth","The question is meaningless"]

Кроме того, у нас есть некоторые IO -специфические структуры управления, а точнее, функции. Они взяты из модуля Control.Monad.

-- when b op performs op if b is true
when :: Bool -> IO () -> IO ()
-- unless b op performs op if b is false
unless :: Bool -> IO () -> IO ()
-- do something many times, collect results
replicateM :: Int -> IO a -> IO [a]
-- do something many times, throw away the results
replicateM_ :: Int -> IO a -> IO ()
-- do something for every list element
mapM :: (a -> IO b) -> [a] -> IO [b]
-- do something for every list element, throw away the results
mapM_ :: (a -> IO b) -> [a] -> IO ()
-- the same, but arguments flipped
forM  :: [a] -> (a -> IO b) -> IO [b]
forM_ :: [a] -> (a -> IO b) -> IO ()

Используя их, мы можем переписать наши предыдущие примеры:

printList :: [Int] -> IO ()
printList xs = mapM_ print xs
readAndSum n = do
  numbers <- replicateM n readLn
  return (sum numbers)
ask :: [String] -> IO [String]
ask questions = do
  forM questions askOne

askOne :: String -> IO String
askOne question = do
  putStr question
  putStrLn "?"
  getLine

11.6 Несколько слов о do и отступах

Легко столкнуться со странными проблемами углубления при использовании нотации. Вот некоторые эмпирические правила, которые помогут вам сделать это правильно.

Главное правило отступов в do: все операции блока должны начинаться в одном столбце.

Некоторые примеры этого правила:

-- This is not OK, putStrLn is way too left
foo = do y <- getLine
   putStrLn y

-- This is not OK either
foo = do y <- getLine
           putStrLn y

-- This is OK
foo = do y <- getLine
         putStrLn y

-- This is also OK: putting a line break after do
foo = do
  y <- getLine
  putStrLn y

Связанное правило: если операция занимает несколько строк, последующие строки должны иметь дополнительный отступ. Иначе компилятор воспримет их как начало новой операции.

-- This is not OK, the string starts a new operation
quux = do putStrLn
          "this long string"
          print 1

-- This is OK
quux = do putStrLn
            "this long string"
          print 1

Вот еще один пример, с вложенными блоками и двумя различными действительными углублениями.

-- This is OK
foo x = do quux
           y <- blorg
           when y (do thing
                      otherThing)
           return 3

-- This is also OK: starting putting a line break after do, using $
foo x = do
  quux
  y <- blorg
  when y $ do
    thing
    otherThing
  return 3

11.7 Давайте напишем программу

После всех этих коротких разовых примеров давайте обратимся к чему-то более длинному. Давайте напишем программу для получения аннотаций всех типов из всех файлов .hs. Мы используем операции ввода-вывода, такие как readFile и listDirectory, для чтения и поиска файлов, а также чистый код, такой как map и filter, для фактической обработки. Во-первых, вот краткий обзор библиотечных операций, которые мы используем:

-- split string into lines
lines :: String -> [String]
-- `isSuffixOf suf list` is true if list ends in suf
Data.List.isSuffixOf :: Eq a => [a] -> [a] -> Bool
-- `isInfixOf inf list` is true if inf occurs inside list
Data.List.isInfixOf :: Eq a => [a] -> [a] -> Bool
-- FilePath is just an alias for String
type FilePath = String
-- get entire contents of file
readFile :: FilePath -> IO String
-- list files in directory
System.Directory.listDirectory :: FilePath -> IO [FilePath]
-- is the given file a directory?
System.Directory.doesDirectoryExist :: FilePath -> IO Bool

А вот и сама программа. Вы также можете найти его в хранилище курса как exercises/Examples/ReadTypes.hs.

module Examples.ReadTypes where

import Control.Monad (forM)
import Data.List (isInfixOf, isSuffixOf)
import System.Directory (listDirectory, doesDirectoryExist)

-- a line is a type signature if it contains :: but does not contain =
isTypeSignature :: String -> Bool
isTypeSignature s = not (isInfixOf "=" s) && isInfixOf "::" s

-- return list of types for a .hs file
readTypesFile :: FilePath -> IO [String]
readTypesFile file
  | isSuffixOf ".hs" file = do content <- readFile file
                               let ls = lines content
                               return (filter isTypeSignature ls)
  | otherwise             = return []

-- list children of directory, prepend directory name
qualifiedChildren :: String -> IO [String]
qualifiedChildren path = do childs <- listDirectory path
                            return (map (\name -> path++"/"++name) childs)

-- get type signatures for all entries in given directory
-- note mutual recursion with readTypes
readTypesDir :: String -> IO [String]
readTypesDir path = do childs <- qualifiedChildren path
                       typess <- forM childs readTypes
                       return (concat typess)

-- recursively read types contained in a file or directory
-- note mutual recursion with readTypesDir
readTypes :: String -> IO [String]
readTypes path = do isDir <- doesDirectoryExist path
                    if isDir then readTypesDir path else readTypesFile path

-- main is the IO action that gets run when you run the program
main :: IO ()
main = do ts <- readTypes "."
          mapM_ putStrLn ts

Мы можем запустить эту программу, перейдя в каталог exercises/Examples и запустив:

$ stack runhaskell ReadTypes.hs
deposit :: String -> Int -> Bank -> Bank
withdraw :: String -> Int -> Bank -> (Int,Bank)
runBankOp :: BankOp a -> Bank -> (a,Bank)
... and so on

Точный вывод будет варьироваться в зависимости от содержимого каталога, конечно.

11.8 Что все это значит?

Вернёмся в функциональный мир. Как согласовать операции IO с чистотой и ленивостью Haskell? Значение вроде putStrLn :: String -> IO () — чистая функция, возвращающая операцию: putStrLn x всегда одинаково при одинаковом x. Иными словами, операция является чистым описанием последовательности побочных эффектов; сами эффекты возникают только при выполнении операции. При запуске программы Haskell среда выполняет одну операцию — main :: IO (). Остальные операции выполняются лишь тогда, когда связаны с main.

Когда в GHCi выражение, которое вы вводите в оценку операции, GHCi выполняет эту операцию для вас. Вот демонстрация чистоты print:

Prelude> x = print 1   -- creates operation, doesn't run it
Prelude> x             -- runs the operation
1
Prelude> x             -- runs it again!
1

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

choice :: IO x -> IO x -> IO x
choice a b =
  do putStr "a or b? "
     x <- getLine
     case x of "a" -> a
               "b" -> b
               _ -> do putStrLn "Wrong!"
                       choice a b
Prelude> choice (putStrLn "A!!!!") (putStrLn "B!!!!")
a or b? z
Wrong!
a or b? a
A!!!!

Использование операций, заданных в качестве параметров, позволяет нам записывать такие функции, как mapM_, с которыми мы встречались ранее. Реализация представляет собой рекурсивную операцию ввода-вывода, которая принимает другую операцию ввода-вывода в качестве параметра. Концептуально сложный, но простой при чтении кода:

mapM_ :: (a -> IO b) -> [a] -> IO ()
mapM_ op     [] = return ()       -- do nothing for an empty list
mapM_ op (x:xs) = do op x         -- run operation on first element
                     mapM_ op xs  -- run operation on rest of list, recursively
Prelude> mapM_ print [1,2,3]
1
2
3

11.9 Еще одна вещь: IORef

До сих пор мы использовали только терминальный ввод-вывод (getLine, print) и работу с файлами (readFile, listDirectory). В императивных программах на Java, Python или C встречаются и другие побочные эффекты, которые нельзя выразить чистой функцией Haskell. Один из них — изменяемое состояние (mutable state). Чистая функция не может читать изменяемое состояние: иначе два вызова одной функции с одинаковыми аргументами могли бы вернуть разные значения.

Haskell типа IORef a из модуля Data.IORef является изменяемой ссылкой на значение типа a

newIORef :: a -> IO (IORef a)                -- create a new IORef containing a value
readIORef :: IORef a -> IO a                 -- produce value contained in IORef
writeIORef :: IORef a -> a -> IO ()          -- set value in IORef
modifyIORef :: IORef a -> (a -> a) -> IO ()  -- modify value contained in IORef with a pure function

Вот несколько примеров использования IORef в GHCi:

Prelude> :m +Data.IORef
Prelude Data.IORef> myRef <- newIORef "banana"
Prelude Data.IORef> readIORef myRef
"banana"
Prelude Data.IORef> writeIORef myRef "apple"
Prelude Data.IORef> readIORef myRef
"apple"
Prelude Data.IORef> modifyIORef myRef reverse
Prelude Data.IORef> readIORef myRef
"elppa"

Вот пример использования IORef для суммирования значений в списке. Обратите внимание на сходство с императивной петлей.

sumList :: [Int] -> IO Int
sumList xs = do r <- newIORef 0                       -- initialize r to 0
                forM_ xs (\x -> modifyIORef r (x+))   -- for every xs, add it to r
                readIORef r                           -- get last value of r

Использование IORef не требуется большую часть времени. Хаскелл предпочитает рекурсию, аргументы и обратные значения. Однако программам реального мира иногда может потребоваться один или два IORef.

11.10 Резюме IO

Значение типа IO X — это операция IO, которая при выполнении производит значение типа X. Сами операции являются чистыми значениями; побочные эффекты возникают только при их выполнении (running).

Операции ввода-вывода могут быть объединены с использованием do -нотации:

op :: X -> IO Y
op arg = do operation                 -- run operation
            operation2 arg            -- run operation with argument
            result <- operation3 arg  -- run operation with argument, store result
            let something = f result  -- run a pure function f, store result
            finalOperation            -- last operation produces the return value

Операция return x - это операция, которая всегда производит значение x. Когда x :: a, return x :: IO a.

Полезные операции IO:

-- printing & reading
putStr :: String -> IO ()
putStrLn :: String -> IO ()
print :: Show a => a -> IO ()
getLine :: IO String
readLn :: Read a => IO a

-- control structures from Control.Monad
when :: Bool -> IO () -> IO ()   -- when b op performs op if b is true
unless :: Bool -> IO () -> IO () -- unless b op performs op if b is false
replicateM :: Int -> IO a -> IO [a]   -- do something many times, collect results
replicateM_ :: Int -> IO a -> IO ()   -- do something many times, throw away the results
mapM ::  (a -> IO b) -> [a] -> IO [b] -- do something for every list element
mapM_ :: (a -> IO b) -> [a] -> IO ()  -- do something for every list element, throw away the results
forM ::  [a] -> (a -> IO b) -> IO [b] -- the same, but arguments flipped
forM_ :: [a] -> (a -> IO b) -> IO ()

-- files
readFile :: FilePath -> IO String

11.11 Викторина

Каков тип этой операции IO?

foo x = do putStrLn x
           y <- getLine
           return (length y)
  1. String -> IO String
  2. IO Int
  3. String -> IO Int
  4. IO String -> IO Int

Какие из них можно использовать вместо ????

quux :: String -> IO [String]
quux q = do y <- getLine
            z <- getLine
            putStrLn (y++z)
            ????
  1. q <- getLine
  2. return (y++z)
  3. return [q]
  4. ans <- return [y,z]

Какие значения печатает blorg [1,2,3]? То есть, какие значения x он называет print x для. Значение, произведенное blorg, не учитывается.

blorg [] = return 0
blorg (x:xs) = do m <- blorg xs
                  print x
                  return (m+x)
  1. Издательство: 1, 2, 3
  2. Печатает 1, 2, 3, 6
  3. Издательство: 3, 2, 1
  4. Печатает 3, 2, 1, 6

Какую из них может выполнять функция типа Int -> IO Int?

  1. Функции этого типа не могут быть определены.
  2. Возврат постоянной стоимости.
  3. Запустите операцию ввода-вывода, которая была предоставлена, и верните ее стоимость.
  4. Запросить у пользователя номер и вернуть его.

Какую из них может выполнять функция типа IO Int -> Int?

  1. Функции этого типа не могут быть определены.
  2. Возврат постоянной стоимости.
  3. Запустите операцию ввода-вывода, которая была предоставлена, и верните ее стоимость.
  4. Запросить у пользователя номер и вернуть его.

11.12 Упражнения

  • Set11a Основные Упражнения IO
  • Set11b Продвинутые Упражнения IO

12 Лекция 12: fmap fmap fmap

12.1 Содержание

  • Функторы

12.2 Функторы

12.2.1 Сохраняющая структура

Помните функцию map для списков? Вот опять определение:

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map g (x:xs) = g x : map g xs

Функция g :: a -> b применяется к каждому элементу списка типа [a], а результатом становится список типа [b]; именно это делает map. Её тип можно также записать как (a -> b) -> ([a] -> [b]). Это тот же тип, поскольку оператор -> правоассоциативен. Дополнительные скобки подчёркивают, что map превращает функцию g :: a -> b в функцию map g :: [a] -> [b]. Значит, mapфункция высшего порядка, преобразующая функции в функции.

Поскольку map является параметрически полиморфным, его определение не зависит от типа значений, хранящихся в списке. Таким образом, каждая функция типа a -> b преобразуется в функцию типа [a] -> [b] с использованием точно такой же логики. Используя приведенное выше определение, мы видим, что:

map (|| True) [True, True, False]
  ==> [True || True, True || True, False || True]
  ==> [True, True, True]

map (+1) [1,2,3]
  ==> [1 + 1, 2 + 1, 3 + 1]
  ==> [2, 3, 4]

map (++"1") ["1", "2", "3"]
  ==> ["1" ++ "1", "2" ++ "1", "3" ++ "1"]
  ==> ["11", "21", "31"]

Важно, что map сохраняет структуру списка: его длина и взаимное расположение элементов не меняются. Общая идея показана на рисунке ниже.

Сопоставление функции g со списком

Попробуем найти другие похожие функции. Значение типа Maybe a напоминает список длиной не более одного элемента. Определим отображение над Maybe. Видите сходство с определением map?

mapMaybe :: (a -> b) -> Maybe a -> Maybe b
mapMaybe f Nothing = Nothing
mapMaybe f (Just x) = Just (f x)

Здесь также сохраняется структура стоимости. Nothing превращается в Nothing, а Just превращается в Just. Здесь мы также можем думать о типе как (a -> b) -> (Maybe a -> Maybe b), преобразуя (или «поднимая») нормальную функцию в функцию, которая работает на Maybes.

Еще один пример: двоичные деревья.

data Tree a = Leaf | Node a (Tree a) (Tree a)

mapTree :: (a -> b) -> Tree a -> Tree b
mapTree f Leaf = Leaf
mapTree f (Node val left right) = Node (f val) (mapTree f left) (mapTree f right)

Бинарное дерево может выглядеть так:

Бинарное дерево

После mapTree g дерево будет выглядеть так:

Бинарное дерево после отображения g в его узлах

12.2.2 Класс Functor

Теперь у нас есть три различные функции сохранения структуры. Три похожие операции над разными типами. Можем ли мы написать класс типов, чтобы уловить это сходство?

map      :: (a -> b) ->      [a] ->      [b]
mapMaybe :: (a -> b) -> Maybe a  -> Maybe b
mapTree  :: (a -> b) -> Tree  a  -> Tree  b

Наивная попытка написать класс типа сталкивается с проблемами. Если мы попытаемся абстрагироваться от Maybe c, то, похоже, мы не сможем написать правильный тип для операции с картой. Нам нужно как-то изменить параметр типа c.

class Mappable m where
  mapThing :: (a -> b) -> m -> m

instance Mappable (Maybe c) where
  mapThing :: (a -> b) -> Maybe c -> Maybe c
  mapThing = ...

К счастью, классы типа Haskell имеют функцию, которую мы не рассматривали ранее. Вы можете писать классы для конструкторов type в дополнение к типам. Что это значит? Давайте просто посмотрим на стандартный класс типа Functor, который делает то, что мы пытались сделать с нашим Mappable.

class Functor f where
  fmap :: (a -> b) -> f a -> f b

Обратите внимание, что параметр типа f является конструктором типа: он передается аргументами a и b в разных частях типа fmap. Рассмотрим пример с Maybe.

instance Functor Maybe where
  -- In this instance, the type of fmap is:
  -- fmap :: (a -> b) -> Maybe a -> Maybe b
  fmap f Nothing = Nothing
  fmap f (Just x) = Just (f x)

Теперь у fmap нужный тип, поэтому её можно реализовать как mapMaybe. Обратите внимание: мы объявили instance Functor Maybe, а не instance Functor (Maybe a). Конкретный тип Maybe a не является функтором; им является конструктор типов Maybe.

Конструктор типа списка записывается как []; это специальный синтаксис, как и остальные списочные обозначения. Если бы тип [a] записывался как List a, конструктору [] соответствовало бы имя List.

instance Functor [] where
  fmap = map

Вот последний из наших примеров, например, Functor.

data Tree a = Leaf | Node a (Tree a) (Tree a)

instance Functor Tree where
  fmap _ Leaf = Leaf
  fmap f (Node val left right) = Node (f val) (fmap f left) (fmap f right)

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

Давайте немного увеличим масштаб. Когда у нас есть экземпляр Functor MyFun, мы знаем, что мы можем нанести на карту тип X в новый тип MyFun X (поскольку MyFun является конструктором типа), но также мы можем поднять функцию f, которая принимает аргумент X в функцию fmap f, которая принимает аргумент MyFun X! Таким образом, вы можете сказать, что мы отображаем как на уровне типа, так и на уровне значения.

Да, еще кое-что. После того, как вы получили название fmap, вы можете использовать его совсем немного. Для кода, который сильно использует fmap, может быть неплохо использовать его псевдоним <$>. Рассмотрим симметрию между $ и <$> в следующих примерах:

(+1) <$> [1,2,3]    ==>  [2,3,4]
not <$> Just False  ==>  Just True

reverse . tail  $       "hello"       ==>  "olle"
reverse . tail <$> Just "hello"       ==>  Just "olle"
-- which is the same as
fmap (reverse . tail) (Just "hello")  ==>  Just "olle"

12.3 Корректные экземпляры

Что же это за «сохранение структуры», о которой говорилось выше? Следующие два законы о функторах Ожидается, что для любого Functor пример f (хотя, к сожалению, компиляторы Haskell не могут их обеспечить):

  1. fmap id === id
  2. fmap (f . g) === fmap f . fmap g

Не волнуйтесь, если это прозвучит абстрактно! Первый закон гласит, что функтор отображает id :: a -> a в id :: f a -> f a. (id является функцией идентификации, что означает id x = x.) Давайте будем конкретны и посмотрим, как это работает для списка [1,2,3]:

fmap id [1,2,3] ==> map id [1,2,3]
                ==> map id (1:[2,3])
                ==> id 1 : map id [2,3]
                ==> 1 : map id [2,3]
                ==> 1 : id 2 : map id [3]
                ==> 1 : 2 : id 3 : map id []
                ==> 1 : 2 : 3 : []
                === [1,2,3]

С другой стороны,

id [1,2,3] ==> [1,2,3]

Следовательно, результат fmap id [1,2,3] То же самое, что результат id [1,2,3] Таким образом, в данном случае действует первый закон функтора. Нетрудно показать, что первый закон о функторах относится к любому списку.

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

Как насчет второго закона о функторах? Для списков рассмотрим, что происходит, если мы fmap функция negate.(*2) (помните, negate карты x на -x и (*2) умножает свой аргумент на 2):

fmap (negate.(*2)) [1,2,3] ==> map (negate.(*2)) [1,2,3]
                           ==> (negate.(*2)) 1 : map (negate.(*2)) [2,3]
                           ==> negate (1 * 2)  : map (negate.(*2)) [2,3]
                           ==> -2 : map (negate.(*2)) [2,3]
                           ==> -2 : (negate.(*2)) 2 : map (negate.(*2)) [3]
                           ==> -2 : -4 : map (negate.(*2)) [3]
                           ==> -2 : -4 : (negate.(*2)) 3 : map (negate.(*2)) []
                           ==> -2 : -4 : -6 : []
                           ==> [-2,-4,-6]

Рассмотрим правую сторону второго закона функтора в данном случае:

(fmap negate . fmap (*2)) [1,2,3] ==> (map negate . map (*2)) [1,2,3]
                                  ==> map negate (map (*2) [1,2,3])
                                  ==> map negate [2,4,6]
                                  ==> [-2,-4,-6]

Второй закон функтора оказывается в данном конкретном случае. На самом деле, он действует во всех случаях (Упражнения!).

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

Существуют также функции высшего порядка, которые не удовлетворяют законам функтора. Рассмотрим функцию badMap:

badMap :: (a -> b) -> [a] -> [b]
badMap f [] = []
badMap f (x:y:xs) = f x : badMap f xs
badMap f (x:xs) = f x : badMap f xs

Эта функция нарушает первый закон функтора. Например:

badMap id [1,2,3] ==> badMap id (1:2:[3])
                  ==> id 1 : badMap id [3]
                  ==> 1 : badMap id [3]
                  ==> 1 : badMap id (3:[])
                  ==> 1 : id 3 : badMap id []
                  ==> 1 : 3 : []
                  ==> [1,3]

применять badMap id В список [1,2,3] Изменить список как элемент 2 Его бросили.

Как уже упоминалось, компиляторы Haskell не могут определить, подчиняется ли функтор своим законам или нет. Компилятор Haskell с радостью примет экземпляр Functor [], который использовал badMap вместо map в качестве реализации fmap. Это ограничение типа системы Haskell. Существуют такие технологии, как LiquidHaskell или языки с зависимой типизацией, такие как Agda, Idris, Coq или Lean, которые могут на самом деле обеспечивать соблюдение законов о функторах, чтобы незаконные функторные экземпляры не компилировались. Однако эти технологии выходят за рамки этого курса.

12.4 Примечание: роды типов (Kinds)

Напомним: Functor — класс конструкторов типов. Если попытаться определить экземпляр Functor для обычного типа, мы получим ошибку:

Prelude> instance Functor Int where

<interactive>:1:18: error:
Expected kind ‘* -> *’, but ‘Int’ has kind ‘*
In the first argument ofFunctor’, namely ‘Int
      In the instance declaration for ‘Functor Int

В сообщении об ошибке упомянуты роды типов (kinds) — своего рода «типы типов». Типы, способные содержать значения, например Int, Bool или Maybe Int, имеют род *. Род конструктора типа похож на тип функции: например, род Maybe* -> *. Это означает, что конструктор Maybe нужно применить к типу рода *, чтобы получить тип рода *.

Мы можем попросить GHCi для типов:

Prelude> :kind Int
Int :: *
Prelude> :kind Maybe
Maybe :: * -> *
Prelude> :kind Maybe Int
Maybe Int :: *

Если мы попросим GHCi предоставить нам информацию о классе Functor, мы увидим, что экземпляры Functor должны иметь вид * -> *:

Prelude> :info Functor
class Functor (f :: * -> *) where
  fmap :: (a -> b) -> f a -> f b
...

Вот некоторые примеры еще более сложных видов.

-- multiple type parameters
Prelude> :kind Either
Either :: * -> * -> *
Prelude> data Either3 a b c = Left a | Middle b | Right c
Prelude> :kind Either3
Either3 :: * -> * -> * -> *
-- a type parameter of kind *->*
Prelude> data IntInside f = IntInside (f Int)
Prelude> :kind IntInside
IntInside :: (* -> *) -> *

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

12.5 И снова Foldable

Мы кратко рассмотрели класс Foldable, который встречается во многих типах подписей основных функций, в части 1. Например:

length :: Foldable t => t a -> Int
sum :: (Foldable t, Num a) => t a -> a
minimum :: (Foldable t, Ord a) => t a -> a
foldMap :: (Foldable t, Monoid m) => (a -> m) -> t a -> m

Из этих сигнатур видно, что Foldable, как и Functor, — класс конструкторов типов (то есть сущностей рода * -> *). По сути, Foldable описывает структуры, которые можно свернуть. Минимальное определение класса могло бы выглядеть так:

class Foldable (t :: *->*) where
  foldr :: (a -> b -> b) -> b -> t a -> b

Тем не менее, по причинам производительности, класс содержит много методов (вы можете увидеть их сами, проверив :info Foldable в GHCi!), но когда мы определяем экземпляр для Foldable, достаточно определить только foldr.

На Foldable можно смотреть как на класс структур, элементы которых обрабатываются слева направо (left-to-right). Иными словами, если Functor — класс контейнеров, то Foldable — класс упорядоченных контейнеров.

В качестве примера давайте реализуем Functor и Foldable для нашего собственного парного типа.

data Pair a = Pair a a
  deriving Show

instance Functor Pair where
  -- fmap f applies f to all values
  fmap f (Pair x y) = Pair (f x) (f y)

instance Foldable Pair where
  -- just like applying foldr over a list of length 2
  foldr f initialValue (Pair x y) = f x (f y initialValue)

-- an example function that uses both instances
doubleAndCount :: (Functor f, Foldable f) => f Int -> Int
doubleAndCount = sum . fmap (*2)

Теперь мы можем использовать Pair практически везде, где мы можем использовать список:

fmap (+1) (Pair 3 6)   ==> Pair 4 7
fmap (+1) [3,6]        ==> [4,7]

foldr (*) 1 (Pair 3 6) ==> 18
foldr (*) 1 [3,6]      ==> 18

length (Pair 3 6)      ==> 2
length [3,6]           ==> 2

minimum (Pair 3 6)     ==> 3
minimum [3,6]          ==> 3

doubleAndCount (Pair 3 6)  ==> 18
doubleAndCount [3,6]       ==> 18

Другие типы Foldable включают Data.Map и Data.Array.

12.6 Резюме

Таким образом, подытоживая, функтор представляет собой конструктор типа f и соответствующий экземпляр Functor f, такой, что fmap удовлетворяет двум законам функтора. Эти законы утверждают, что fmap должен сохранять функцию идентификации и распределять по функциональному составу. Более неофициально, fmap поднимает функцию g :: a -> b, работающую на значениях, до одной, работающей на контейнерах: fmap g :: f a -> f b. В основном все хорошо функционирующие структуры данных в Haskell являются функторами.

12.7 Викторина

Что такое fmap?

  1. a -> b -> f a -> f b
  2. (a -> b) -> f a -> f b
  3. Functor f => a -> b -> f a -> f b
  4. Functor f => (a -> b) -> f a -> f b

Какой фрагмент кода завершает следующий экземпляр Functor?

data Container x = Things x [x]

instance Functor Container where
  ????
  1. fmap f (Things x ys) = Things (f x) [f x]
  2. fmap f (Things x ys) = Things (f x) (map f ys)
  3. fmap f (Things x ys) = Things (f x) ys
  4. fmap f (Things x ys) = f (Things x ys)

Что такое [a]?

  1. *
  2. * -> *
  3. [a]

Что такое Foo?

data Foo x = FooConst
  1. *
  2. * -> *
  3. Foo

Что такое Bar?

data Bar = Baz | Qux Int
  1. *
  2. * -> *
  3. Bar

Какова стоимость foldr (-) 1 (Just 2)?

  1. -1
  2. 1
  3. Just -1
  4. Just 1

Какой фрагмент кода завершает следующий экземпляр Foldable?

data Container x = Things x [x]

instance Foldable Container where
  ????
  1. foldr f z (Things x ys) = f x z
  2. foldr f z (Things x ys) = foldr f x ys
  3. foldr f z (Things x ys) = f x (foldr f z ys)
  4. foldr f z (Things x ys) = foldr f z (x:ys)

12.8 Упражнения

13 Лекция 13: Моноид в категории проблем

  • Монады

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

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

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

И последнее предостережение: монады, как и функторы, происходят из раздела математики, называемого теорией категорий. Однако важно подчеркнуть: чтобы понять монады в программировании на Haskell, не нужно знать теорию категорий и даже интересоваться ею. С объектно-ориентированным или функциональным программированием можно работать без математической теории объектов или функций; так же можно использовать монады, не зная лежащей за ними математики. Теория категорий может быть полезна функциональному программисту, но не обязательна.

13.1 Пример 1: Maybe

При работе со многими значениями Maybe код имеет тенденцию становиться немного беспорядочным. Давайте рассмотрим некоторые примеры. Во-первых, мы объединяем некоторые функции возврата Maybe String. Обратите внимание на вложенный case, который нам нужен в stealSecret: писать не весело.

-- Try to login with a password.
-- `Just username` on success, `Nothing` otherwise.
login :: String -> Maybe String
login "f4bulous!" = Just "unicorn73"
login "swordfish" = Just "megahacker"
login _           = Nothing

-- Get a secret associated with a user.
-- Not all users have secrets.
secret :: String -> Maybe String
secret "megahacker" = Just "I like roses"
secret _            = Nothing

-- Login and return the user's secret, if any
stealSecret :: String -> Maybe String
stealSecret password =
  case login password of
    Nothing -> Nothing
    Just user -> case secret user of
                   Nothing -> Nothing
                   Just s -> Just ("Stole secret: "++s)
stealSecret "swordfish"  ==>  Just "Stole secret: I like roses"
stealSecret "f4bulous!"  ==>  Nothing
stealSecret "wrong_password"  ==>  Nothing

Далее мы изменим список пар. Мы используем Maybe - функция возврата lookup Из прелюдии. Здесь у нас есть "если" внутри дела, а не вложенный случай.

-- Get the value corresponding to a key from a key-value list.
lookup :: (Eq a) => a -> [(a, b)] -> Maybe b
-- Set the value of key to val in the given key-value list,
-- but only if val is larger than the current value!
increase :: Eq a => a -> Int -> [(a,Int)] -> Maybe [(a,Int)]
increase key val assocs =
  case lookup key assocs
  of Nothing -> Nothing
     Just x -> if (val < x)
                then Nothing
                else Just ((key,val) : delete (key,x) assocs)

Этот тип кода довольно распространен и обычно повторяет один и тот же шаблон: если какой-либо промежуточный результат является Nothing, то весь результат является Nothing. Давайте попробуем упростить написание кода, определив оператора chaining ?>. Оператор цепей принимает результат и следующий шаг вычисления, и выполняет следующий шаг только в том случае, если результатом было значение Just.

(?>) :: Maybe a -> (a -> Maybe b) -> Maybe b
-- if we failed, don't even bother running the next step:
Nothing ?> _ = Nothing
-- otherwise run the next step:
Just x  ?> f = f x

Оператор цепей красиво обтекает наши примеры. Обратите внимание, как мы можем определить простые вспомогательные функции, которые выполняют один шаг вычислений вместо написания одного большого выражения.

stealSecret :: String -> Maybe String
stealSecret password =
    login password ?>
    secret ?>
    decorate
  where decorate s = Just ("Stole secret: "++s)
increase :: Eq a => a -> Int -> [(a,Int)] -> Maybe [(a,Int)]
increase key val assocs =
    lookup key assocs ?>
    check ?>
    buildResult
  where check x
           | val < x   = Nothing
           | otherwise = Just x
        buildResult x = Just ((key,val) : delete (key,x) assocs)

Вот еще один пример: индексация безопасного списка, построенная из safeHead и safeTail:

safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x:xs) = Just x

safeTail :: [a] -> Maybe [a]
safeTail [] = Nothing
safeTail (x:xs) = Just xs

safeThird :: [a] -> Maybe a
safeThird xs = safeTail xs ?> safeTail ?> safeHead

safeNth :: Int -> [a] -> Maybe a
safeNth 0 xs = safeHead xs
safeNth n xs = safeTail xs ?> safeNth (n-1)
safeThird [1,2,3,4]
  ==> Just 3
safeThird [1,2]
  ==> Nothing
safeNth 5 [1..10]
  ==> Just 6
safeNth 11 [1..10]
  ==> Nothing

P.S. Обратите внимание: ?> левоассоциативен, как и большинство операторов Haskell по умолчанию. Поэтому op ?> f ?> g означает (op ?> f) ?> g. Выражение op ?> (f ?> g) даже не пройдёт проверку типов.

Примечание: этот оператор ?> выражает шаблон if-result, который очень распространен в других языках. Вот как можно написать op val ?> f на Python и Java.

# Python
x = op(val)
if x:
  f(x)
// Java
Object x = op(val);
if (x != null) {
  f(x);
}

Разница между шаблоном if-result и нашей ?> заключается в том, что мы используем значение Nothing для явного сбоя сигнала, вместо того, чтобы полагаться на тот факт, что любая переменная может быть None (или False) в Python, или что любая ссылка Object может быть null в Java.

13.2 Пример 2: логирование (logging)

Рассмотрим связывание ещё на одном примере — логировании. Тип Logger хранит значение и список сообщений журнала, созданных вычислением этого значения.

-- Logger definition
data Logger a = Logger [String] a  deriving Show

getVal :: Logger a -> a
getVal (Logger _ a) = a
getLog :: Logger a -> [String]
getLog (Logger s _) = s

-- Primitive operations:
nomsg :: a -> Logger a
nomsg x = Logger [] x        -- a value, no message

annotate :: String -> a -> Logger a
annotate s x = Logger [s] x  -- a value and a message

msg :: String -> Logger ()
msg s = Logger [s] ()        -- just a message

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

validateUser :: String -> Logger Bool
validateUser "paul.atreides" = annotate "Valid user" True
validateUser "ninja" = nomsg True
validateUser u = annotate ("Invalid user: "++u) False

checkPassword :: String -> String -> Logger Bool
checkPassword "paul.atreides" "muad'dib" = annotate "Password ok" True
checkPassword "ninja"         ""         = annotate "Password ok" True
checkPassword _               pass       = annotate ("Password wrong: "++pass) False

login :: String -> String -> Logger Bool
login user password =
  let validation = validateUser user
  in if (getVal validation)
       then let check = checkPassword user password
            in Logger (getLog validation ++ getLog check) (getVal check)
       else validation
login "paul.atreides" "muad'dib"
  ==> Logger ["Valid user","Password ok"] True
login "paul.atreides" "arrakis"
  ==> Logger ["Valid user","Password wrong: arrakis"] False
login "ninja" ""
  ==> Logger ["Password ok"] True
login "leto.atreides" "paul"
  ==> Logger ["Invalid user: leto.atreides"] False

Давайте попробуем упростить этот код, определив оператора цепей для Logger. При выполнении нескольких операций Logger важно сохранить все журналы. Вот оператор цепей #> и пример того, как его можно использовать для регистрации некоторых арифметических вычислений.

(#>) :: Logger a -> (a -> Logger b) -> Logger b
Logger la a #> f = let Logger lb b = f a  -- feed value to next step
                   in Logger (la++lb) b   -- bundle result with all messages
-- square a number and log a message about it
square :: Int -> Logger Int
square val = annotate (show val ++ "^2") (val^2)

-- add 1 to a number and log a message about it
add :: Int -> Logger Int
add val = annotate (show val ++ "+1") (val+1)

-- double a number and log a message about it
double :: Int -> Logger Int
double val = annotate (show val ++ "*2") (val*2)

-- compute the expression 2*(x^2+1) with logging
compute :: Int -> Logger Int
compute x =
    square x
    #> add
    #> double
compute 3
  ==> Logger ["3^2","9+1","10*2"] 20

Мы можем немного упростить login, используя #>. Обратите внимание, что нам не нужно беспокоиться о объединении журналов вместе. Также обратите внимание, как мы используем выражение лямбда вместо определения функции помощника.

login :: String -> String -> Logger Bool
login user password =
  validateUser user
  #>
  \valid -> if valid then checkPassword user password
                     else nomsg False

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

-- sometimes you don't need the previous value:
(##>) :: Logger a -> Logger b -> Logger b
Logger la _ ##> Logger lb b = Logger (la++lb) b

filterLog :: (Eq a, Show a) => (a -> Bool) -> [a] -> Logger [a]
filterLog f [] = nomsg []
filterLog f (x:xs)
   | f x       = msg ("keeping "++show x) ##> filterLog f xs #> (\xs' -> nomsg (x:xs'))
   | otherwise = msg ("dropping "++show x) ##> filterLog f xs
filterLog (>0) [1,-2,3,-4,0]
  ==> Logger ["keeping 1","dropping -2","keeping 3","dropping -4","dropping 0"] [1,3]

13.3 Пример 3: работа с состоянием (State)

В предыдущем примере мы просто написали некоторое состояние (журнал). Иногда нам нужны вычисления, которые меняют какое-то общее состояние. Рассмотрим счета в небольшом банке. Сначала мы определим тип данных для состояния банка: остатки всех счетов, как карту от имени счета до баланса.

import qualified Data.Map as Map

data Bank = Bank (Map.Map String Int)
  deriving Show

Вот как мы можем внести деньги на счет. Для изменения карты мы используем функцию adjust от Data.Map.

-- Apply a function to one value in a map
Map.adjust :: Ord k => (a -> a) -> k -> Map.Map k a -> Map.Map k a
deposit :: String -> Int -> Bank -> Bank
deposit accountName amount (Bank accounts) =
  Bank (Map.adjust (\x -> x+amount) accountName accounts)

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

-- Fetch the value corresponding to a key from a map,
-- or a default value in case the key does not exist
Map.findWithDefault :: Ord k => a -> k -> Map.Map k a -> a
withdraw :: String -> Int -> Bank -> (Int,Bank)
withdraw accountName amount (Bank accounts) =
  let -- balance is 0 for a nonexistant account
      balance = Map.findWithDefault 0 accountName accounts
      -- can't withdraw over balance
      withdrawal = min amount balance
      newAccounts = Map.adjust (\x -> x-withdrawal) accountName accounts
  in (withdrawal, Bank newAccounts)

Наконец, напишите функцию, которая берет не более 100 денег с одного счета, делит деньги пополам и вносит их на два счета. Обратите внимание на то, как мы должны тщательно обработать различные версии банка, bank, bank1, bank2 и bank3, чтобы убедиться, что все транзакции происходят в правильном порядке.

share :: String -> String -> String -> Bank -> Bank
share from to1 to2 bank =
  let (amount,bank1) = withdraw from 100 bank
      half = div amount 2
      -- carefully preserve all money, even if amount was an odd number
      rest = amount-half
      bank2 = deposit to1 half bank1
      bank3 = deposit to2 rest bank2
  in bank3
share "wotan" "siegfried" "brunhilde"
      (Bank (Map.fromList [("brunhilde",0),("siegfried",0),("wotan",1000)]))
   ==> Bank (Map.fromList [("brunhilde",50),("siegfried",50),("wotan",900)])

share "wotan" "siegfried" "brunhilde"
      (Bank (Map.fromList [("brunhilde",0),("siegfried",0),("wotan",91)]))
   ==> Bank (Map.fromList [("brunhilde",46),("siegfried",45),("wotan",0)])

Такой код часто появляется в Haskell, когда вы делаете последовательные обновления до одного значения, а также выполняете некоторые другие вычисления на стороне. Это легко сделать ошибку, и система типа не поможет вам, если вы, например, повторно использовать bank1 ценность. Давайте перепишем share Для этого не нужно обращаться к самому банку. Мы можем снова использовать ту же самую цепную идею для достижения этой цели.

-- `BankOp a` is an operation that transforms a Bank value,
-- while returning a value of type `a`
data BankOp a = BankOp (Bank -> (a,Bank))

-- running a BankOp on a Bank
runBankOp :: BankOp a -> Bank -> (a,Bank)
runBankOp (BankOp f) bank = f bank

-- Running one BankOp after another
(+>>) :: BankOp a -> BankOp b -> BankOp b
op1 +>> op2 = BankOp combined
  where combined bank = let (_,bank1) = runBankOp op1 bank
                        in runBankOp op2 bank1

-- Running a parameterized BankOp, using the value returned
-- by a previous BankOp.  The implementation is a bit tricky
-- but it's enough to understand how +> is used for now.
(+>) :: BankOp a -> (a -> BankOp b) -> BankOp b
op +> parameterized = BankOp combined
  where combined bank = let (a,bank1) = runBankOp op bank
                        in runBankOp (parameterized a) bank1

-- Make a BankOp out of deposit.
-- There is no return value so we use ().
depositOp :: String -> Int -> BankOp ()
depositOp accountName amount = BankOp depositHelper
  where depositHelper bank = ((), deposit accountName amount bank)

-- Make a BankOp out of withdraw. Note how
--   withdraw accountName amount :: Bank -> (Int,Bank)
-- is almost a BankOp already!
withdrawOp :: String -> Int -> BankOp Int
withdrawOp accountName amount = BankOp (withdraw accountName amount)

Давайте посмотрим, как работает цепочка с этими банковскими операциями.

Prelude> bank = Bank (Map.fromList [("edsger",10),("grace",50)])

-- Running a number of operations using +>>

Prelude> runBankOp (depositOp "edsger" 1) bank
((),Bank (fromList [("edsger",11),("grace",50)]))

Prelude> runBankOp (depositOp "edsger" 1 +>> depositOp "grace" 1) bank
((),Bank (fromList [("edsger",11),("grace",51)]))

Prelude> runBankOp (depositOp "edsger" 1 +>> depositOp "grace" 1 +>> withdrawOp "edsger" 11) bank
(11,Bank (fromList [("edsger",0),("grace",51)]))

-- Using +> to implement a transfer from one account to the other:

Prelude> runBankOp (withdrawOp "edsger" 5 +> depositOp "grace") bank
((),Bank (fromList [("edsger",5),("grace",55)]))

Prelude> runBankOp (withdrawOp "edsger" 100 +> depositOp "grace") bank
((),Bank (fromList [("edsger",0),("grace",60)]))

Значение типа BankOp описывает процесс, преобразующий состояние банка. Начальное состояние передаётся через runBankOp. Это разумно: преобразования BankOp можно компоновать, в отличие от самих состояний Bank. Явный вызов runBankOp также помогает различать описание операций и их выполнение.

Теперь, когда мы знакомы с манипулированием значениями BankOp, мы можем реализовать share как BankOp. Мы реализуем помощника distributeOp, чтобы сделать код немного аккуратнее.

-- distribute amount to two accounts
distributeOp :: String -> String -> Int -> BankOp ()
distributeOp to1 to2 amount =
  depositOp to1 half
  +>>
  depositOp to2 rest
  where half = div amount 2
        rest = amount - half

shareOp :: String -> String -> String -> BankOp ()
shareOp from to1 to2 =
  withdrawOp from 100
  +>
  distributeOp to1 to2
runBankOp (shareOp "wotan" "siegfried" "brunhilde")
          (Bank (Map.fromList [("brunhilde",0),("siegfried",0),("wotan",1000)]))
  ==> ((),Bank (Map.fromList [("brunhilde",50),("siegfried",50),("wotan",900)]))

runBankOp (shareOp "wotan" "siegfried" "brunhilde")
          (Bank (Map.fromList [("brunhilde",0),("siegfried",0),("wotan",91)]))
  ==> ((),Bank (Map.fromList [("brunhilde",46),("siegfried",45),("wotan",0)]))

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

Вы можете найти весь этот код в репозитории курса под exercises/Examples/Bank.hs.

13.4 Наконец-то: класс типов Monad

Теперь мы видели три различных типа с цепной операцией:

(?>) :: Maybe a -> (a -> Maybe b) -> Maybe b
(#>) :: Logger a -> (a -> Logger b) -> Logger b
(+>) :: BankOp a -> (a -> BankOp b) -> BankOp b

Как и ранее с map и Functor, есть класс типов, который захватывает этот шаблон. Отметим, что Monad является классом для конструкторов type, как и Functor.

class Monad m where
  (>>=) :: m a -> (a -> m b) -> m b

Существуют дополнительные операции в Monad также:

  -- lift a normal value into the monad
  return :: a -> m a
  -- simpler chaining (like our ##>)
  (>>) :: m a -> m b -> m b
  a >> b  =  a >>= \_ -> b     -- remember: _ means ignored argument

Напомним, класс Functor обобщает операцию map. Аналогично, класс Monad обобщает операцию связывания в цепочку.

fmap :: Functor f => (a->b) -> f a -> f b
(>>=) :: Monad m => m a -> (a -> m b) -> m b

Выражение operation >>= next принимает монадическую операцию operation :: m a и делает некоторые дальнейшие вычисления со значением, которое он производит с использованием next :: a -> m b. Если это кажется слишком абстрактным, просто вспомните, как работает цепь для Maybe:

(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
-- if we failed, don't even bother running the next step
Nothing >>= _ = Nothing
-- otherwise run the next step
Just x  >>= f = f x

13.5 Maybe — это монада!

Вот полный экземпляр Monad для Maybe и некоторые примеры.

instance  Monad Maybe  where
    (Just x) >>= k      = k x
    Nothing  >>= _      = Nothing

    (Just _) >>  k      = k
    Nothing  >>  _      = Nothing

    return x            = Just x
Just 1 >>= \x -> return (x+1)
  ==> Just 2
Just "HELLO" >>= (\x -> return (length x)) >>= (\x -> return (x+1))
  ==> Just 6
Just "HELLO" >>= \x -> Nothing
  ==> Nothing
Just "HELLO" >> Just 2
  ==> Just 2
Just 2 >> Nothing
  ==> Nothing

Вот примеры stealSecret и increase, переписанные с операциями монады. Изменения коснулись ?> в >>= и Just в return.

stealSecret :: String -> Maybe String
stealSecret password =
    login password >>=
    secret >>=
    decorate
  where decorate s = return ("Stole secret: "++s)
-- Set the value of key to val in the given key-value list,
-- but only if val is larger than the current value!
increase :: Eq a => a -> Int -> [(a,Int)] -> Maybe [(a,Int)]
increase key val assocs =
    lookup key assocs >>=
    check >>=
    buildResult
  where check x
           | val < x   = Nothing
           | otherwise = return x
        buildResult x = return ((key,val) : delete (key,x) assocs)

13.6 Возвращение do

Вот пример того, как может выглядеть сложная операция монады.

f = op1 >>= continue
  where continue  x   = op2 >> op3 >>= continue2 x
        continue2 x y = op4 >> op5 x y

Давайте посмотрим, что происходит, когда мы немного трансформируем этот код. Прежде всего, давайте введем определения.

f = op1 >>= (\x ->
               op2 >>
               op3 >>= (\y ->
                          op4 >>
                          op5 x y))

Из-за лямбда-выражений, продолжающихся до конца выражения, мы можем опустить скобки. Давайте также по-другому отступим.

f = op1 >>= \x ->
    op2 >>
    op3 >>= \y ->
    op4 >>
    op5 x y

Теперь можно заметить сходство с обозначением do. Блок do ниже на самом деле тот же код!

f = do x <- op1
       op2
       y <- op3
       op4
       op5 x y

Для уточнения, нотация do является просто более приятным синтаксисом для операций монады (>>= и >>) и лямбда. Вот как нотация трансформируется в монадные операции. Записка! Определение является рекурсивным.

do x <- op a       ~~~>       op a >>= \x -> do ...
   ...
do op a            ~~~>       op a >> do ...
   ...
do let x = expr    ~~~>       let x = expr in do ...
   ...
do finalOp         ~~~>       finalOp

Вот safeNth с помощью обозначения:

safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x:xs) = Just x

safeTail :: [a] -> Maybe [a]
safeTail [] = Nothing
safeTail (x:xs) = Just xs

safeNth :: Int -> [a] -> Maybe a
safeNth 0 xs = safeHead xs
safeNth n xs = do t <- safeTail xs
                  safeNth (n-1) t

Вот increase в последний раз, сейчас с нотацией

-- Set the value of key to val in the given key-value list,
-- but only if val is larger than the current value!
increase :: Eq a => a -> Int -> [(a,Int)] -> Maybe [(a,Int)]
increase key val assocs =
  do oldVal <- lookup key assocs
     check oldVal
     return ((key,val) : delete (key,oldVal) assocs)
  where check x
           | val < x   = Nothing
           | otherwise = return x

13.7 Logger — это монада!

Мы должны быть в состоянии написать Monad Например, Logger себя, устанавливая >>= то #> Однако из-за Последние изменения в языке Haskell Мы должны реализовать Functor и Applicative инстанциям разрешается осуществлять Monad Например. Functor Мы уже встречались, но что Applicative Мы узнаем позже. Давайте реализуем примеры:

import Control.Monad

data Logger a = Logger [String] a
  deriving Show

msg :: String -> Logger ()
msg s = Logger [s] ()

-- The Functor instance just maps over the stored value
instance Functor Logger where
  fmap f (Logger log x) = Logger log (f x)

-- This is an Applicative instance that works for any
-- monad, you can just ignore it for now. We'll get back
-- to Applicative later.
instance Applicative Logger where
  pure = return
  (<*>) = ap

-- Finally, the Monad instance
instance Monad Logger where
  return x = Logger [] x
  Logger la a >>= f = Logger (la++lb) b
    where Logger lb b = f a

Нам больше не нужна операция nomsg, потому что это просто return. Мы также можем реализовывать операцию annotate с использованием операций монады.

nomsg :: a -> Logger a
nomsg x = return x

annotate :: String -> a -> Logger a
annotate s x = msg s >> return x

Вот примеры compute и filterLog, переписанные с помощью донотации. Обратите внимание, насколько хорош filterLog с донотацией.

compute x = do
  a <- annotate "^2" (x*x)
  b <- annotate "+1" (a+1)
  annotate "*2" (b*2)

filterLog :: (Show a) => (a -> Bool) -> [a] -> Logger [a]
filterLog f [] = return []
filterLog f (x:xs)
   | f x       = do msg ("keeping "++show x)
                    xs' <- filterLog f xs
                    return (x:xs')
   | otherwise = do msg ("dropping "++show x)
                    filterLog f xs
compute 3
  ==> Logger ["^2","+1","*2"] 20
filterLog (>0) [1,-2,3,-4,0]
  ==> Logger ["keeping 1","dropping -2","keeping 3","dropping -4","dropping 0"] [1,3]

13.8 Монада State

Монада Haskell State является обобщенной версией нашего типа BankOp. Тип State параметризируется двумя типами, первый из которых является типом состояния, а второй типом произведенной стоимости. State Bank a будет эквивалентен нашему BankOp a. Монаду State можно найти в модуле Control.Monad.Trans.State пакета transformers. Вот упрощенная реализация State.

data State s a = State (s -> (a,s))

runState (State f) s = f s

-- operation that overwrites the state (and produces ())
put :: s -> State s ()
put state = State (\oldState -> ((),state))

-- operation that produces the current state
get :: State s s
get = State (\state -> (state,state))

-- operation that modifies the current state with a function (and produces ())
modify :: (s -> s) -> State s ()
modify f = State (\state -> ((), f state))

-- Functor and Applicative instances skipped

instance Monad (State s) where
  return x = State (\s -> (x,s))

  op >>= f = State h
    where h state0 = let (val,state1) = runState op state0
                         op2 = f val
                     in runState op2 state1

Обратите внимание на объявление экземпляра Monad (State s). Здесь используется частично применённый конструктор типа, поскольку экземпляр Monad можно объявить только для конструктора, принимающего ещё один параметр типа. Это станет понятнее, если сравнить роли m, Maybe и State в приведённых ниже типах >>=.

class Monad m where
  (>>=) :: m a -> (a -> m b) -> m b

instance Monad Maybe where
  (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b

instance Monad (State s) where
  (>>=) :: State s a -> (a -> State s b) -> State s b

Рассмотрим несколько примеров работы с State. Для начала рассмотрим вычисления типа State Int a, которые представляют собой работу с простым счетчиком.

-- adds i to the value of the counter
add :: Int -> State Int ()
add i = do old <- get
           put (old+i)
runState (add 1 >> add 3 >> add 5 >> add 6) 0
  ==> ((),15)
example :: State Int Int
example = do add 3           -- increment state by 3
             value <- get    -- value is current state, i.e. initial+3
             add 1000        -- increment state by 1000
             put (value + 1) -- overwrite state with value+1, i.e. initial+4
             return value    -- produce value, i.e. intial+3
runState example 1
  ==> (4,5)           -- initial is 1, state is initial+4=5, produces initial+3=4

Обратите внимание: значение типа State s a представляет процесс, преобразующий состояние, — подобно BankOp. Начальное состояние передаётся через runState. Необходимость явно вызывать runState помогает различать описание операций и их выполнение.

Состояние может заменить параметр аккумулятора при обработке списка. Вот два примера: нахождение самого большого элемента списка и нахождение значений в списке, которые происходят непосредственно после 0.

findLargest :: Ord a => [a] -> State a ()
findLargest [] = return ()
findLargest (x:xs) = do
  modify (\y -> max x y)  -- update state with max of current value and previous largest value
  findLargest xs          -- process rest of list
runState (findLargest [1,2,7,3]) 0  ==>  ((),7)
-- store the given value in the state list
remember :: a -> State [a] ()
remember x = modify (x:)

valuesAfterZero :: [Int] -> ((),[Int])
valuesAfterZero xs = runState (go xs) []
  where go :: [Int] -> State [Int] ()
        go (0:y:xs) = do remember y
                         go (y:xs)
        go (x:xs) = go xs
        go [] = return ()
valuesAfterZero [0,1,2,3,0,4,0,5,0,0,6]
  ==> ((),[6,0,5,4,1])

Записка! Таким образом, фактическое осуществление State Монада не имеет State Конструктор, как и наш упрощенный пример. Если вы хотите превратить функцию в State Вместо этого используйте этот помощник:

state :: (s -> (a, s)) -> State s a

13.9 Возвращение mapM

Управляющие конструкции из лекции об IO работают во всех монадах. Ниже приведены их настоящие типы.

when :: Monad m => Bool -> m () -> m ()        -- conditional operation
unless :: Monad m => Bool -> m () -> m ()      -- same, but condition is flipped
replicateM :: Monad m => Int -> m a -> m [a]   -- do something many times
replicateM_ :: Monad m => Int -> m a -> m ()   -- same, but ignore the results
mapM :: Monad m => (a -> m b) -> [a] -> m [b]  -- do something on a list's elements
mapM_ :: Monad m => (a -> m b) -> [a] -> m ()  -- same, but ignore the results
forM  :: Monad m => [a] -> (a -> m b) -> m [b] -- mapM but arguments reversed
forM_ :: Monad m => [a] -> (a -> m b) -> m ()  -- same, but ignore the results

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

mapM (\x -> if (x>0) then Just (x-1) else Nothing) [1,2,3]  ==>  Just [0,1,2]
mapM (\x -> if (x>0) then Just (x-1) else Nothing) [1,0,3]  ==>  Nothing

mapM (\x -> msg "increment" >> msg (show x) >> return (x+1)) [1,2,3]
  ==> Logger ["increment","1","increment","2","increment","3"] [2,3,4]

runState (mapM (\x -> modify (x+) >> return (x+1)) [1,2,3]) 0
  ==> ([2,3,4],6)

Еще несколько примеров:

safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x:xs) = Just x
firsts :: [[a]] -> Maybe [a]
firsts xs = forM xs safeHead
firsts [[1,2,3],[4,5],[6]] ==> Just [1,4,6]
firsts [[1,2,3],[],[6]]    ==> Nothing
-- an abbreviated version of an example from the last section
findLargest :: Ord a => [a] -> State a ()
findLargest xs = mapM_ update xs
  where update x = modify (\y -> max x y)
runState (findLargest [1,2,7,3]) 0  ==>  ((),7)
let increment = modify (+1) >> get
    ops = replicateM 4 increment
in runState ops 0
  ==> ([1,2,3,4],4)

Вот filter, реализуемый с использованием монады State:

rememberElements :: (a -> Bool) -> [a] -> State [a] ()
rememberElements f xs = mapM_ maybePut xs
  where maybePut x = when (f x) (modify (++[x]))

sfilter :: (a -> Bool) -> [a] -> [a]
sfilter f xs = finalState
  where (_, finalState) = runState (rememberElements f xs) []
sfilter even [1,2,3,4,5]
  ==> [2,4]

Мы можем писать свои собственные операции, которые работают для всех монад. Это стало возможным благодаря классам, как мы видели раньше. Если вы используете только монадные операции, такие как return и do-notation, система типов выведет общий тип для вашей функции.

mywhen b op = if b then op else return ()

mymapM_ op [] = return ()
mymapM_ op (x:xs) = do op x
                       mymapM_ op xs
*Main> :t mywhen
mywhen :: (Monad m) => Bool -> m () -> m ()
*Main> :t mymapM_
mymapM_ :: (Monad m) => (t -> m a) -> [t] -> m ()

Мы можем использовать эти общие операции в каждом из наших примеров монад:

perhapsDecrease :: Int -> Maybe Int
perhapsDecrease x = do
  mywhen (x<=0) Nothing
  return (x-1)
perhapsDecrease 2  ==>  Just 1
perhapsDecrease 0  ==>  Nothing
search :: (Show a, Eq a) => a -> [a] -> Logger ()
search x ys = mymapM_ look ys
  where look y = mywhen (x==y) (msg ("Found "++show y))
search 3 [1,2,3,4,3,2]  ==>  Logger ["Found 3","Found 3"] ()
sumPositive :: [Int] -> State Int ()
sumPositive xs = mymapM_ f xs
  where f x = when (x>0) (modify (x+))
runState (sumPositive [1,-4,2,3]) 0  ==>  ((),6)

13.10 Монады — это функторы

Еще не введена одна полезная операция: liftM.

liftM :: Monad m => (a->b) -> m a -> m b
liftM f op = do x <- op
                return (f x)

Операция liftM позволяет легко писать код с чистыми и монадическими частями.

liftM negate (Just 3)
  ==> Just (-3)

liftM sort $ firsts [[4,6],[2,1,0],[3,3,3]]
  ==> Just [2,3,4]

runState (liftM negate get) 3
  ==> (-3,3)

Выглядит ли тип liftM знакомым? Это как fmap! На самом деле, легко определить функтор для монады: просто установите fmap = liftM. Поскольку каждый Monad должен быть Functor в наши дни, современный стиль Haskell предпочитает fmap по сравнению с liftM.

fmap :: Functor f => (a->b) -> f a -> f b
fmap negate (Just 3)
  ==> Just (-3)

fmap sort $ firsts [[4,6],[2,1,0],[3,3,3]]
  ==> Just [2,3,4]

runState (fmap negate get) 3
  ==> (-3,3)

13.11 Ещё одна монада

Список монад (то есть, это Monad Например, []) представляет собой вычисления с множественные значения возврата Это полезно для поиска альтернатив. Вот первый пример. Для каждого x Мы производим оба x и -x:

[1,2,3] >>= \x -> [-x,x]
  ==> [-1,1,-2,2,-3,3]

Мы можем отфильтровать неподходящие значения, создав пустой список:

[1,2,3] >>= \x -> if x>1 then [x] else []
  ==> [2,3]

Если мы используем донотацию, список монад начинает больше напоминать петлевую конструкцию:

do word <- ["Blue", "Green"]
   number <- [1,2,3]
   return (word ++ show number)
  ==> ["Blue1","Blue2","Blue3","Green1","Green2","Green3"]

Более интересный пример: найдите все пары в списке, который суммируется в k. (Один и тот же элемент дважды считается парой.)

findSum :: [Int] -> Int -> [(Int,Int)]
findSum xs k = do a <- xs
                  b <- xs
                  if (a+b==k) then [(a,b)] else []
findSum [1,2,3,4,5] 5
  ==> [(1,4),(2,3),(3,2),(4,1)]

Последний, более сложный пример. Мы находим все палиндромы из строки, используя список монад, а затем находим самую длинную.

import Data.List (sortBy)

substrings :: String -> [String]
substrings xs = do start <- [0..length xs - 1]
                   end <- [start+1..length xs - 1]
                   return $ drop start $ take end $ xs

palindromesIn :: String -> [String]
palindromesIn xs = do s <- substrings xs
                      if (s==reverse s) then return s else []

longestPalindrome xs = head . sortBy f $ palindromesIn xs
  where f s s' = compare (length s') (length s)  -- longer is smaller
palindromesIn "aabbacddcaca"
  ==> ["a","aa","a","abba","b","bb","b","a","acddca","c","cddc","d","dd","d","c","cac","a","c"]
longestPalindrome "aabbacddcaca"
  ==> "acddca"

Вот удивительно простая реализация Монады списка:

instance Monad [] where
  return x = [x]                  -- an operation that produces one value
  lis >>= f = concat (map f lis)  -- compute f for all values, combine the results

На самом деле, мы видели список монады ранее под видом понимания списка. Сравните эту реконструкцию findSum с предыдущей, в которой используется нотация do.

findSum :: [Int] -> Int -> [(Int,Int)]
findSum xs k = [(a,b) | a <- xs, b <- xs, a+b==k ]

13.12 Ах да, IO

Как вы уже, наверное, догадались, IO — это монада. Однако реализации типа IO и instance Monad IO являются встроенными компиляторами. Вы не можете реализовать монаду IO только с использованием стандарта Haskell, в отличие от монады Maybe, монады State и других монад, которые мы видели.

Настоящие побочные эффекты подчиняются монадическому шаблону так же, как State и Maybe. Как и с другими монадами, мы отделяем чистое описание операций от их выполнения. Кроме того, с IO можно использовать общие монадические функции, включая mapM и родственные ей.

Вот несколько примеров написания IO с использованием операций монады.

printTwoThings :: IO ()
printTwoThings = putStrLn "One!" >> print 2

echo :: IO ()
echo = getLine >>= putStrLn

verboseEcho :: IO ()
verboseEcho = getLine >>= \s -> putStrLn ("You wrote: " ++ s)

query :: String -> IO String
query question = putStrLn question >> getLine

confirm :: String -> IO Bool
confirm question = putStrLn question >> fmap interpret getLine
    where interpret "Y" = True
          interpret _ = False
Prelude> printTwoThings
One!
2

Prelude> verboseEcho
The Iliad
You wrote: The Iliad

Prelude> answer <- query "Why am I here?"
Why am I here?
Good question!
Prelude> answer
"Good question!"

Prelude> b <- confirm "Fire warheads?"
Fire warheads?
no no no no
Prelude> b
False
Prelude> b <- confirm "Make love, not war?"
Make love, not war?
Y
Prelude> b
True

13.13 Монады на других языках

Как только вы познакомитесь с концепцией монады, вы начнете видеть монадоподобные вещи и на других языках. Наиболее известными примерами этого являются Option типов, Java Streams и JavaScript обещаний. Если вы знаете эти языки или концепции, вы можете найти этот раздел освещающим. Если нет, не стесняйтесь пропустить это.

13.13.1 Опциональные значения

Многие языки имеют option типа . Этот тип называется Optional<T> на Java, std::optional<T> на C++, Nullable<T> на C# и так далее. Эти типы часто имеют поведение, напоминающее монаду Haskell Maybe, например:

  • В Java Optional.flatMap соответствует >>=: он позволяет применять Function<T,Optional<U>> к Optional<T> и получить Optional<U>.
  • В C# бинарные операции получают автоматически lifted типам Nullable. Например, a + null становится null.

13.13.2 Потоки

Java Streams также имеет monadlike API. Потоки создают много значений постепенно. Как и в случае с Optional, метод Stream.flatMap позволяет взять Stream<T>, объединить его с Function<T,Stream<U>> и получить Stream<U>.

Например, если lines является Stream<String>, words берет String и возвращает Stream<String> и readInt берет String и возвращает Integer, мы можем написать:

Stream<Integer> parseNumbers(Stream<String> lines) {
    return lines.flatMap(words).map(read);
}

Это соответствует следующему коду монады списка Haskell:

parseNumbers :: [String] -> [Int]
parseNumbers strings = fmap read (strings >>= words)
parseNumbers ["123 456","7 89"]  ==>  [123,456,7,89]

13.13.3 Обещания

Существует much disagreement о том, являются ли обещания в JavaScript really монадами или нет. Однако некоторые сходства очевидны.

Сначала рассмотрим сходство между Promise.then и >>=. Обе операции принимают действие — обещание или монадическую операцию — и связывают его с функцией, возвращающей новое действие.

function concatPromises(promise1, promise2) {
  return promise1.then(value1 => promise2.then(value2 => value1+value2));
}
>> concatPromises(Promise.resolve("abc"), Promise.resolve("def")).then(console.log)
abcdef
concatMonadic :: Monad m => m String -> m String -> m String
concatMonadic op1 op2 = op1 >>= (\value1 -> op2 >>= (\value2 -> return (value1++value2)))
Prelude> concatMonadic (Just "abc") (Just "def")
Just "abcdef"

Давайте рассмотрим сходство между асинхронизацией / ожиданием и нотацией. Оба являются более приятными синтаксисами для работы с необработанными механизмами Promise.then или >>=. Мы реализуем concatPromises с использованием асинхронизации / ожидания и concatMonadic с использованием донотации. Их поведение остается прежним.

async function concatPromises(promise1, promise2) {
  let value1 = await promise1;
  let value2 = await promise2;
  return value1+value2;
}
concatMonadic :: Monad m => m String -> m String -> m String
concatMonadic op1 op2 = do
  value1 <- op1
  value2 <- op2
  return (value1++value2)

13.14 Монады: подведём итоги

  • Класс типов Monad позволяет представлять разные способы выполнения последовательностей действий
    • сбой (Maybe)
    • логирование
    • состояние
    • недетерминизм (монада списков)
    • ввод-вывод (IO)
  • Вы можете написать код монады двумя эквивалентными способами:
    • Использование операций класса Monad (>>=, >>) напрямую
    • Использование нотации do
  • Если M — монада, то значения типа M a представляют операции, производящие результат типа a
  • Монады - это шаблон дизайна и библиотека (mapM и т.д.
    • Использование общих абстракций облегчает понимание кода.
    • Читать операцию State проще, чем расшифровать сложную рекурсию с состоянием
  • Все, что вы можете сделать с монадами, вы также можете сделать без них.
    • Исключение: IO
    • Использование монады часто упрощает код
  • Предупреждение Интернет полон учебных пособий, пытающихся объяснить монады с помощью простой аналогии.
    • По моему опыту, это не работает
    • Что работает, так это использование разных монад и медленное привыкание к концепции.

13.15 Примечание: стандартный Haskell

Эта и предыдущая лекция охватывали многие части, в которых версия Haskell GHC отличается от стандартной Haskell 2010. Вот краткий список изменений, внесенных GHC, чтобы вы знали:

  • length, sum, foldr и др. обобщены для работы на Foldable вместо списков
  • Functor и Applicative являются суперклассами Monad.
  • Метод fail был перенесен из класса типа Monad в собственный класс MonadFail.

13.16 Викторина

Какое выражение эквивалентно следующему блоку?

do y <- z
   s y
   return (f y)
  1. z >> \y -> s y >> return (f y)
  2. z >>= \y -> s y >> return (f y)
  3. z >> \y -> s y >>= return (f y)

Какой тип \x xs -> return (x : xs)?

  1. Monad m => a -> [a] -> m [a]
  2. Monad m => a -> [m a] -> [m a]
  3. a -> [a] -> Monad [a]
  4. Ни один из вышеперечисленных

Какой тип \x xs -> return x : xs?

  1. Monad m => a -> [a] -> m [a]
  2. Monad m => a -> [m a] -> [m a]
  3. a -> [a] -> Monad [a]
  4. Ни один из вышеперечисленных

Какой тип (\x xs -> return x) : xs?

  1. Monad m => a -> [a] -> m [a]
  2. Monad m => a -> [m a] -> [m a]
  3. a -> [a] -> Monad [a]
  4. Ни один из вышеперечисленных

13.17 Упражнения

14 Лекция 14: Используйте библиотеки!

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

Note! При чтении документации для библиотек не забудьте обратить внимание на библиотечную версию. Вы можете увидеть версии, используемые на курсе в файле tests.cabal. Ссылки в материале курса всегда приводят вас к правильной версии, как и команда stack haddock --open <package>. См. также раздел «Чтение документации» в части 1.

14.1 Text и ByteString

До сих пор мы использовали тип Haskell String для работы со строками. Однако String — это просто [Char], связанный список персонажей. Это ужасно неэффективно как с точки зрения памяти, так и с точки зрения времени. Как только мы выходим за рамки обработки коротких строк и начинаем обрабатывать целые файлы или сетевые запросы, более эффективный по времени тип строк становится обязательным.

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

  • Data.Text представляет собой a последовательность символов Unicode, как и String, только более эффективная. Используется при работе с текстом.
  • Data.ByteString представляет собой последовательность a байтов. Используется при работе с двоичными данными.

Кроме того, оба этих типа поставляются в вариантах lazy и strict. docs для Data.Text хорошо суммируют разницу:

Строгий тип Text требует, чтобы целая строка вписывалась в память сразу. Ленивый тип Text способен передавать потоковые строки, которые больше, чем память, с использованием небольшого объема памяти. Каждый модуль обеспечивает практически идентичный API.

Все эти типы (Text и ByteString, строгие и ленивые) предлагают функции pack и unpack для преобразования из и в простые String. Типы также поставляются со специализированными версиями знакомых функций списка, таких как reverse, take, map и так далее.

14.1.1 Примеры с Text

Давайте пройдемся по короткому сеансу GHCi, демонстрирующему использование GHCi. Data.Text Как документация говорит, Data.Text Модуль предназначен для импорта квалифицированный Мы можем преобразовать String входить в Text с функцией T.pack Обратите внимание, как значение типа Text Печатается так же, как и String.

Prelude> import qualified Data.Text as T
Prelude T> :t T.pack
T.pack :: String -> T.Text
Prelude T> phrase = T.pack "brevity is the soul of wit"
Prelude T> :t phrase
phrase :: T.Text
Prelude T> phrase
"brevity is the soul of wit"

Мы можем использовать функции Data.Text оперировать значениями Text Многие из них названы так же, как и их коллеги. String s или списки из Prelude.

Prelude T> :t T.length
T.length :: T.Text -> Int
Prelude T> T.length phrase
26
Prelude T> T.head phrase
'b'
Prelude T> T.take 4 phrase
"brev"
Prelude T> :t T.words
T.words :: T.Text -> [T.Text]
Prelude T> T.words phrase
["brevity","is","the","soul","of","wit"]
Prelude T> :t T.map
T.map :: (Char -> Char) -> T.Text -> T.Text
Prelude T> T.map (\c -> if c=='o' then '0' else c) phrase
"brevity is the s0ul 0f wit"

Полезной деталью является то, что Text имеет экземпляр Monoid, который склеивает значения Text вместе. Также можно использовать функции T.append и T.concat.

Prelude T> phrase <> phrase
"brevity is the soul of witbrevity is the soul of wit"
Prelude T> T.append phrase phrase
"brevity is the soul of witbrevity is the soul of wit"
Prelude T> T.concat [phrase,phrase,phrase]
"brevity is the soul of witbrevity is the soul of witbrevity is the soul of wit"

Если вы хотите написать рекурсивную функцию, которая соответствует шаблону на Text, как на String, вы можете использовать функцию T.uncons :: T.Text -> Maybe (Char, T.Text), чтобы разделить Text на голову и хвост. Вот простой пример:

countLetter :: Char -> T.Text -> Int
countLetter c t =
  case T.uncons t of
    Nothing -> 0
    Just (x,rest) -> (if x == c then 1 else 0) + countLetter c rest
Prelude T> countLetter 't' phrase
3

14.1.1.1 Строгость и лень

Обратите внимание, что Data.Text Реализует строгий Text Тип. Вы должны использовать Data.Text.Lazy Для ленивого варианта. Как упоминалось ранее, одно различие между этими двумя типами заключается в том, что строгий тип не работает для бесконечных струн:

Prelude T> T.head (T.pack (repeat 'x'))
-- never returns
Prelude T> import qualified Data.Text.Lazy as TL
Prelude T TL> TL.head (TL.pack (repeat 'x'))
'x'

Еще одна практическая проблема заключается в том, что при использовании библиотек можно получить несоответствие между строгими и ленивыми Text. Обычно это можно исправить, используя toStrict или fromStrict по мере необходимости.

Prelude T TL> lazyPhrase = TL.pack "brevity is the soul of wit"
Prelude T TL> :t lazyPhrase
lazyPhrase :: TL.Text
Prelude T TL> :t phrase
phrase :: T.Text
Prelude T TL> lazyPhrase == phrase

<interactive>: error:
Couldn't match expected typeTL.Text
                  with actual typeT.Text
      NB:T.Text’ is defined inData.Text.Internal
TL.Text’ is defined inData.Text.Internal.Lazy
In the second argument of ‘(==)’, namely ‘phrase’
      In the expression: lazyPhrase == phrase
      In an equation for ‘it’: it = lazyPhrase == phrase

Prelude T TL> :t TL.toStrict
TL.toStrict :: TL.Text -> T.Text
Prelude T TL> :t TL.fromStrict
TL.fromStrict :: T.Text -> TL.Text
Prelude T TL> TL.toStrict lazyPhrase == phrase
True

14.1.2 Примеры с ByteString

Мы можем пройти через почти тот же сеанс GHCi, используя ByteString вместо Text. Однако обратите внимание, что ByteString построен из значений Word8, а не значений Char. Char может представлять собой произвольную однокодовую кодовую точку, как для символа, такого как 'Å', но Word8 представляет собой байт: число от 0 до 255. К сожалению и несколько запутанно, значения ByteString печатаются как String.

Prelude> import Data.Word
Prelude Data.Word> import qualified Data.ByteString as B
Prelude Data.Word B> binary = B.pack [99,111,102,102,101,101]
Prelude Data.Word B> :t binary
binary :: B.ByteString
Prelude Data.Word B> :t B.pack
B.pack :: [Word8] -> B.ByteString
Prelude Data.Word B> binary
"coffee"
Prelude Data.Word B> :t B.length
B.length :: B.ByteString -> Int
Prelude Data.Word B> B.length binary
6
Prelude Data.Word B> :t B.head
B.head :: B.ByteString -> Word8
Prelude Data.Word B> B.head binary
99
Prelude Data.Word B> B.take 4 binary
"coff"
Prelude Data.Word B> :t B.map
B.map :: (Word8 -> Word8) -> B.ByteString -> B.ByteString
Prelude Data.Word B> B.map (+1) binary
"dpggff"

Те же предостережения относятся и к различиям между строгим и ленивым. ByteString как для Text:

Prelude B Data.Char> B.head (B.pack (repeat 99))
-- never returns
Prelude Data.Word B> import qualified Data.ByteString.Lazy as BL
Prelude Data.Word B BL> BL.head (BL.pack (repeat 99))
99
Prelude Data.Word B BL> binary == BL.pack [99]

<interactive>: error:
Couldn't match expected typeB.ByteString
                  with actual typeBL.ByteString
      NB:BL.ByteString’ is defined inData.ByteString.Lazy.Internal
B.ByteString’ is defined inData.ByteString.Internal
In the second argument of ‘(==)’, namely ‘BL.pack [99]’
      In the expression: binary == BL.pack [99]
      In an equation for ‘it’: it = binary == BL.pack [99]

Prelude Data.Word B BL> :t BL.toStrict
BL.toStrict :: BL.ByteString -> B.ByteString
Prelude Data.Word B BL> :t BL.fromStrict
BL.fromStrict :: B.ByteString -> BL.ByteString
Prelude Data.Word B BL> binary == BL.toStrict (BL.pack [99])
False

14.1.3 Примечание: кодировки

Вы, наверное, задаетесь вопросом, почему у нас есть и текст, и струна. Разница тонкая, но реальная. Когда мы работаем с Text, мы работаем с персонажами, независимо от того, что это за символы и как они закодированы. Когда мы работаем на ByteString, мы работаем на байтах, независимо от того, что эти байты представляют.

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

Чтобы проиллюстрировать эту разницу, мы рассмотрим текстовое кодирование UTF-8. Кодирование текста представляет собой способ представления characters как bytes. UTF-8 может представлять все миллионы символов, определенных Unicode. Поскольку байты могут хранить только значения от 0 до 255, это означает, что символ может быть закодирован в несколько байтов. Биты и байты строки UTF-8 «Ha thekeλ!» можно интерпретировать по-разному:

(Если вы видите разные символы на картинке и в «Ha.keλ!», это означает, что ваш браузер либо неправильно интерпретирует кодирование, либо используемый вами шрифт не поддерживает все символы.)

Если мы прочитаем один и тот же поток битов с помощью другого кодирования, мы увидим другие символы. Например, приведенная выше строка будет интерпретирована как «Haââ.«keλ!» с использованием текста Latin-1, кодирующего .

Кстати, при работе с необработанными двоичными данными часто удобно использовать шестидесятичную систему счисления, которая использует один символ 0, 1, ..., 9, A, B, ..., F для представления всех шестнадцати возможных комбинаций из четырех битов. Мы не нуждаемся в шестнадцатеричных в этом курсе, но вы можете проверить Wikipedia, если вы хотите узнать больше о шестнадцатеричных.

Мы можем использовать тот же пример, используя код. Функция Data.Text.Encoding.encodeUtf8 :: Text -> ByteString кодирует символы в Тексте в байты в ByteString с помощью UTF-8.

Prelude> import qualified Data.Text as T
Prelude T> import qualified Data.ByteString as B
Prelude T B> T.length (T.pack "haskell")
7
Prelude T B> T.length (T.pack "Ha∫keλ!")
7
Prelude T B> import Data.Text.Encoding
Prelude T B Data.Text.Encoding> encodeUtf8 (T.pack "haskell")
"haskell"
Prelude T B Data.Text.Encoding> encodeUtf8 (T.pack "Ha∫keλ!")
"Ha\226\136\171ke\206\187!"
Prelude T B Data.Text.Encoding> B.length (encodeUtf8 (T.pack "haskell"))
7
Prelude T B Data.Text.Encoding> B.length (encodeUtf8 (T.pack "Ha∫keλ!"))
10

Если мы обрабатываем ASCII text, то есть символы, которые могут быть представлены с помощью одиночных байтов, мы можем использовать Text и ByteString взаимозаменяемо. Существуют пространства имен Data.ByteString.Char8 и Data.ByteString.Lazy.Char8, которые предлагают функции, которые работают на ByteString, используя значения Char вместо Word8. Тем не менее, необходимо позаботиться о том, чтобы все персонажи действительно были простыми персонажами ASCII, иначе произойдут удивительные вещи.

Prelude T B> import qualified Data.ByteString.Char8 as B8
Prelude T B B8> B8.pack "abc"
"abc"
Prelude T B B8> :t B8.pack
B8.pack :: String -> B.ByteString
Prelude T B B8> :t B.pack
B.pack :: [Word8] -> B.ByteString
Prelude T B B8> B8.cons 'a' (B8.pack "bc")
"abc"
Prelude T B B8> putStrLn (B8.unpack (B8.pack "€λ훈"))  -- non-ASCII characters get truncated
¬»È
Prelude T B B8> putStrLn (T.unpack (T.pack "€λ훈"))
€λ훈

14.2 Монады: повторение

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

  • Если M — монада, значения типа M X представляют операции, которые можно выполнить, чтобы получить значение типа X.
  • Монадические операции могут быть реализованы с использованием
    • методы класса типа Monad (return, >>=, >>),
    • do - примечание,
    • Библиотечные функции, такие как mapM.
  • В отличие от других языков, return — не ключевое слово и не завершает выполнение операции. Выражение return x создаёт операцию, которая возвращает x и больше ничего не делает.
  • Вот как выглядит нотация do:
foo y = do
  operation1         -- run an operation
  val <- operation2  -- run an operation and keep the produced value
  operation3 val y   -- run an operation with parameters
  mapM_ (\x -> operation4 val x) things  -- use a generic monad operation and a lambda
  operation5 val     -- the final line of the do decides which value the whole block produces

14.3 Написание HTTP-сервера: WAI и Warp

Иногда кажется, что всё в мире работает через HTTP и веб-API. Веб-браузер, приложения на смартфоне, ваш банк, кофеварка и даже дверной звонок — все они общаются с серверами по протоколу HTTP.

Давайте рассмотрим, как настроить простой HTTP-сервер в Haskell. Стандартные низкоуровневые компоненты для этого называются WAI и Warp. WAI, интерфейс веб-приложений дает нам способ определить, как обрабатываются HTTP-запросы. Warp - это простой HTTP-сервер, который запускает логику, которую мы определили с помощью WAI. Возможно, сейчас это звучит немного абстрактно, но простой пример поможет.

В файле exercises/Examples/HelloServer.hs реализован HTTP-сервер, который всегда отвечает «Hello World!». Вы можете попробовать его, перейдя в каталог exercises/Examples и запустив его с stack runhaskell HelloServer.hs. После этого вы можете посетить http://localhost:3421 в вашем браузере, чтобы увидеть ответ от сервера.

module Examples.HelloServer where

import qualified Data.ByteString.Lazy.Char8 as BL
import Network.HTTP.Types.Status (status200)
import Network.Wai (Application, responseLBS)
import Network.Wai.Handler.Warp (run)

port :: Int
port = 3421

main :: IO ()
main = run port application

-- type Application = Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
application :: Application
application request respond =
  respond (responseLBS status200 [] (BL.pack "Hello World!"))

Рассмотрим типы из этого примера. Здесь происходит немало. Прежде всего, Applicationпсевдоним типа (type alias) для сущности, реализующей логику веб-сервера. Функция run из Warp запускает Application:

run :: Port -> Application -> IO ()
type Application = Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived

Скоро мы подробнее разберём Request и Response, а пока из сигнатуры видно, что Application — операция IO. Она принимает запрос типа Request и операцию respond :: Response -> IO ResponseReceived. Аргументы вроде respond во многих контекстах называют функциями обратного вызова (callbacks): с их помощью приложение вызывает передавшую их библиотеку. Операция Application должна вернуть то же специальное значение типа ResponseReceived, которое возвращает respond. Этот тип можно считать свидетельством того, что respond действительно была вызвана операцией Application.

Это может показаться пугающим, но если посмотреть на код, все относительно просто: наш server является Application и принимает два параметра: request и respond.

WAI использует множество типов, таких как Port, Request, Response, Status для представления концепций HTTP. Полезно искать их в документации, когда вы сталкиваетесь с ними. Например, Port является псевдонимом для Int. В качестве другого примера можно увидеть функцию responseLBS имеет тип

responseLBS :: Status -> ResponseHeaders -> ByteString -> Response

где Status определено в Network.HTTP.Types.Status, ResponseHeaders является псевдонимом типа для [Header] от Network.HTTP.Types.Header, ByteString является ленивым ByteString, а результат типа Response определяется Network.WAI.

Наконец, обратите внимание, что мы используем функцию Data.ByteString.Lazy.Char8.pack для преобразования String в ByteString. Это работает только для текста ASCII.

Веб-сервер, который всегда отвечает одним и тем же текстом, не так уж интересен. Давайте посмотрим, как мы можем дать разные ответы на разные запросы. В HTTP-запросе есть много частей, но для этой лекции мы сосредоточимся на path. В URL-адресе, таком как http://example.com/abcd/ef/file, часть /abcd/ef/file - это путь. WAI выполняет функцию

pathInfo :: Request -> [Text]

Это дает нам путь запрошенного URL, разделенного на символы /.

Файл exercises/Examples/PathServer.hs реализует веб-сервер, который имеет три разные страницы:

Как и раньше, вы можете запустить сервер, зайдя в каталог exercises/Examples и запустив stack runhaskell PathServer.hs.

14.4 Работа с базой данных: sqlite-simple

После внедрения HTTP-сервера мы можем участвовать в глобальном графике приложений, разговаривающих друг с другом. Но что толку говорить, если мы не можем вспомнить? Реальное приложение должно иметь возможность persist data даже при его перезапуске. Распространенным способом достижения этой цели является использование базы данных.

Существует много различных типов баз данных, но, возможно, наиболее широко используемой простой базой данных является SQLite. SQLite - это библиотека, которая позволяет хранить данные в файле и обрабатывать его с помощью SQL, языка структурированных запросов . С SQLite нет необходимости запускать отдельный сервер базы данных a la PostgreSQL или MySQL.

Если вы не знакомы с SQL, не волнуйтесь, вам не нужно будет писать свои собственные запросы в Упражнениях. Если вы хотите изучить немного SQL сейчас, в Интернете есть много учебных пособий. См. W3Schools, SQL Zoo или Codecademy.

Есть много библиотек для Haskell для использования SQLite, но мы рассмотрим одну под названием sqlite-simple здесь. Давайте немного познакомимся с библиотекой GHCi.

Все функции находятся внутри Database.SQLite.Simple. Вы можете открыть базу данных, указав имя файла в open, которая является операцией ввода-вывода, которая производит Connection.

Prelude> import Database.SQLite.Simple
Prelude Database.SQLite.Simple> :t open
open :: String -> IO Connection
Prelude Database.SQLite.Simple> db <- open "example.sqlite"

Для запуска SQL-запроса можно использовать операцию IO query_, которая принимает Connection и Query и производит список результатов. Тип Query - это просто newtype вокруг Text. Тип результата query_ является полиморфным: из базы данных можно прочитать любой тип, который удовлетворяет классу типа FromRow. Если это кажется запутанным, сравните его с типом read: Read a => String -> a. Класс FromRow похож на Read для этой базы данных: он представляет типы, которые могут быть считаны из базы данных. В любом случае, давайте прочитаем номер 1 из базы данных:

Prelude Database.SQLite.Simple> :t query_
query_ :: FromRow r => Connection -> Query -> IO [r]
Prelude Database.SQLite.Simple> :info Query
newtype Query = Query {fromQuery :: Data.Text.Internal.Text}
    -- Defined in ‘Database.SQLite.Simple.Types’
    -- ... rest of output omitted
Prelude Database.SQLite.Simple> import qualified Data.Text as T
Prelude Database.SQLite.Simple T> q = Query (T.pack "SELECT 1;")
Prelude Database.SQLite.Simple T> res <- query_ db q :: IO [[Int]]
Prelude Database.SQLite.Simple T> res
[[1]]

Кстати, все эти начальные примеры используют простые запросы SELECT x, y, z;, которые просто возвращают постоянные данные. О реальных таблицах в базе данных мы поговорим позже.

Без подписи типа мы получаем ошибку от GHCi, которая не может решить, какой тип мы хотим прочитать из базы данных:

Prelude Database.SQLite.Simple T> res <- query_ db q

<interactive>:17:8: error:
Ambiguous type variable ‘r0’ arising from a use of ‘query_’
      prevents the constraint ‘(FromRow r0)’ from being solved.
      Probable fix: use a type annotation to specify what ‘r0’ should be.
      -- rest of error omitted

Прежде чем мы продолжим, давайте более подробно рассмотрим FromRow. Если вы сталкивались с SQL раньше, вы знаете, что SQL-запрос возвращает ряд rows, и каждая строка состоит из ряда values (также называемый columns). Чтобы интерпретировать результат SQL-запроса в данные Haskell, нам нужен способ интерпретировать эти значения и строки. Таким образом, sqlite-simple определяет два класса, FromField и FromRow, и кучу примеров, таких как: (Вы можете найти эти экземпляры от , docs или запросив GHCi с :info FromRow и т. Д.)

instance FromField Int
instance FromField Bool
instance FromField String
instance FromField Text
instance FromField a => FromRow [a]
instance (FromField a, FromField b) => FromRow (a,b)
instance (FromField a, FromField b, FromField c) => FromRow (a,b,c)

По сути, базовые типы данных Haskell соответствуют классу FromField, а различные коллекции Haskell соответствуют классу FromRow. В нашем предыдущем примере мы использовали экземпляры FromRow [a] и FromField Int, чтобы получить [[Int]] из query_. Вот простой запрос, который использует некоторые другие типы данных:

Prelude Database.SQLite.Simple T> q = Query (T.pack "SELECT 1, true, 'string';")
Prelude Database.SQLite.Simple T> query_ db q :: IO [(Int,Bool,String)]
[(1,True,"string")]

Что делать, если SQL и Haskell не совпадают? Ну, вы получаете ошибку времени выполнения, как если бы вы попытались вызвать read "True" :: Int.

Prelude Database.SQLite.Simple T> query_ db q :: IO [(Int,Int,Int)]
*** Exception: ConversionFailed {errSQLType = "TEXT", errHaskellType = "Int", errMessage = "need an int"}

В дополнение к классам FromRow и FromField библиотека sqlite-simple определяет классы ToRow и ToField для записи в базу данных. Ниже показан тип функции query, позволяющей использовать параметризованные запросы.

query :: (ToRow q, FromRow r) => Connection -> Query -> q -> IO [r]

Вот некоторые примеры для ToRow и ToField:

instance ToField Int
instance ToField Bool
instance ToField String
instance ToField Text
instance ToField Int

instance ToField a => ToRow [a]
instance (ToField a, ToField b) => ToRow (a, b)
instance (ToField a, ToField b, ToField c) => ToRow (a, b, c)
instance ToField a => ToRow (Only a)

Параметризованные запросы используют символ ? для обозначения слотов, в которых параметры могут быть переданы. Вот простой пример:

Prelude Database.SQLite.Simple T> input = (1,"hello") :: (Int,String)
Prelude Database.SQLite.Simple T> parameterized = Query (T.pack "SELECT ?+1, true, ?;")
Prelude Database.SQLite.Simple T> query db parameterized input :: IO [(Int,Bool,String)]
[(2,True,"hello")]

Note! При выполнении запроса с одним параметром можно использовать два экземпляра ToRow: ToField a => ToRow [a] и ToField a => Only a. Тип данных Only определен в Data.Tuple.Only и является своего рода обходным путем для того, что у Haskell нет одноэлементных кортежей. Кроме того, работает список размера 1. То же самое относится к запросам, которые возвращают строки только с одной колонкой: вы можете использовать [[X]] или [Only X] в качестве типа возврата. Вот пример:

Prelude Database.SQLite.Simple T> q = Query (T.pack "SELECT lower(?);")
Prelude Database.SQLite.Simple T> query db q (Only "HELLO") :: IO [Only String]
[Only {fromOnly = "hello"}]
Prelude Database.SQLite.Simple T> query db q ["HELLO"] :: IO [[String]]
[["hello"]]

Это почти все, что вам нужно знать о sqlite-simple: open, query_, query, FromRow, ToRow. Да, еще кое-что. Есть функции execute и execute_, если вам не нужен результат запроса. Например, они полезны для вставки вещей в базу данных.

execute_ :: Connection -> Query -> IO ()
execute :: ToRow q => Connection -> Query -> q -> IO ()

Вы найдете пример программы, которая использует sqlite-simple для поддержания телефонной книги под exercises/Examples/Phonebook.hs. Программа хранит телефонную книгу в файле под названием phonebook.db и работает так (запускается из каталога exercises/Examples в репозитории курса):

$ stack runhaskell Phonebook.hs
(a)dd or (q)uery?
a
Name?
bob
Phone?
1234
$ stack runhaskell Phonebook.hs
(a)dd or (q)uery?
a
Name?
bob
Phone?
5678
$ stack runhaskell Phonebook.hs
(a)dd or (q)uery?
a
Name?
samantha
Phone?
1357
$ stack runhaskell Phonebook.hs
(a)dd or (q)uery?
q
Name?
bob
2 numbers:
["1234"]
["5678"]

PS. Если вы опустошены отсутствием проверки типа компиляции для запросов SQL, вы можете взглянуть на некоторые из более продвинутых библиотек SQL для Haskell, таких как Beam или Opaleye. Этот курс использует sqlite-простой для простоты и чтобы не зацикливаться на деталях SQL.

14.5 Упражнения

  • Set14a Текст & Байтстринг
  • Set14b: HTTP & SQLite

15 Лекция 15: Вы валидны даже без монад (You’re Valid Even Without Monads)

15.1 Введение в аппликативные функторы

Класс типа Applicative - это середина между Functor (с которой вы не можете так много сделать) и Monad (что в значительной степени позволяет писать произвольные программы). Причины использования Applicative вместо Monad включают:

  • Производительность: поскольку Applicative позволяет меньше операций, его можно оптимизировать лучше, чем Monad.
  • Простота: интерфейс Applicative проще обдумать.
  • Необходимость: просто нет способа определить экземпляр Monad для вашего типа, но есть экземпляр Applicative. Это редкость.

Что такое Applicative? Рассмотрим определение.

class Functor f => Applicative f where
  pure :: a -> f a
  liftA2 :: (a -> b -> c) -> f a -> f b -> f c
  -- other operations omitted for now

Таким образом, Applicative - это Functor, который позволяет нам создавать однотонные значения через pure и объединять два значения в одно с использованием liftA2. Это добавляет много энергии по сравнению с голым функтором. Вычисления с использованием функторов обязательно линейны: fmap :: (a -> b) -> f a -> f b принимает одно функторное значение и выводит другое. В отличие от этого, pure не принимает никакого функторного значения и выводит одно, а liftA2 берет два и возвращает одно.

Термин Applicative происходит от термина Applicative Functor, который звучит так, как будто он происходит из Теории Категории, но на самом деле был введен в статью по программированию.

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

instance Applicative Maybe where
  pure x = Just x
  liftA2 f (Just x) (Just y) = Just (f x y)
  liftA2 f _        _        = Nothing

Вы увидите, что определение использует тот же тип распространения отказов, что и экземпляр Monad Maybe. Давайте использовать это при разборе денежных ценностей:

data Currency = EUR | USD
  deriving (Show, Eq)
data Money = Money Int Currency
  deriving (Show, Eq)

parseCurrency :: String -> Maybe Currency
parseCurrency "e" = pure EUR
parseCurrency "€" = pure EUR
parseCurrency "$" = pure USD
parseCurrency _ = Nothing

parseAmount :: String -> Maybe Int
parseAmount = readMaybe

parseMoney :: String -> String -> Maybe Money
parseMoney amountString currencyString =
  liftA2 Money (parseAmount amountString) (parseCurrency currencyString)
parseMoney "123" "€"  ==> Just (Money 123 EUR)
parseMoney "45" "$"   ==> Just (Money 45 USD)
parseMoney "4x" "€"   ==> Nothing
parseMoney "45" "£"   ==> Nothing

Это хорошо сработало. Однако, если мы попытаемся расширить это, мы скоро войдем в пределы Applicative. Например, рассмотрим функцию sumMoney, которая суммирует значения Money, но не работает, если они не находятся в одной валюте:

sumMoney :: Money -> Money -> Maybe Money
sumMoney (Money a c) (Money b c')
    | c == c'   = Just (Money (a+b) c)
    | otherwise = Nothing

Мы не можем применить его к двум значениям Maybe Money с использованием операций Applicative. Для этого нужна монада Maybe:

example :: Maybe Money
example = do x <- parseMoney "123" "€"
             y <- parseMoney "45" "$"
             sumMoney x y

Если мы попытаемся использовать liftA2, мы застрянем с типом Maybe (Maybe Money). Кроме того, теперь мы можем получить два разных типа сбоев: Nothing и Just Nothing, в зависимости от того, на каком уровне происходит ошибка. Это был бы явный пример перехода на экземпляр Monad.

liftA2 sumMoney (parseMoney "123" "e") (parseMoney "45" "€")
  ==> Just (Just (Money 168 EUR))
liftA2 sumMoney (parseMoney "123" "e") (parseMoney "45" "$")
  ==> Just Nothing
liftA2 sumMoney (parseMoney "123" "e") (parseMoney "xxx" "e")
  ==> Nothing

Название liftA2 звучит немного громоздко, но это аналогия с liftM, liftM2 и так далее. Напомним, что liftM был просто fmap, поэтому, возможно, liftA2 должен был называться fmap2.

15.2 Аппликативный функтор для списков

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

instance Applicative [] where
  pure x = [x]
  liftA2 f xs ys = [f x y | x <- xs, y <- ys]

Вот вам пример: генерация фраз.

things :: [String]
things = ["tangerine","bandit","diamond"]

fruits :: [String]
fruits = ["apple", "tangerine"]

phrases :: [String]
phrases = liftA2 combine things fruits
  where combine t f = "a " ++ t ++ " the size of a " ++ f

bunches = liftA2 copy [1,2,3] fruits
  where copy n f = unwords (replicate n f)
phrases ==> ["a tangerine the size of a apple",
             "a tangerine the size of a tangerine",
             "a bandit the size of a apple",
             "a bandit the size of a tangerine",
             "a diamond the size of a apple",
             "a diamond the size of a tangerine"]

bunches ==> ["apple","tangerine",
             "apple apple","tangerine tangerine",
             "apple apple apple","tangerine tangerine tangerine"]

15.3 Новые операторы

Есть несколько операторов для приложений, которые довольно удобны. Это <$>, <*>, <* и *>.

Начнем с <$>, который представляет собой просто инфиксную версию fmap:

(<$>) :: Functor f => (a -> b) -> f a -> f b
f <$> x = fmap f x
not <$> Just True   ==> Just False
not <$> Nothing     ==> Nothing
negate <$> [1,2,3]  ==> [-1,-2,-3]

Это довольно мило само по себе, но это действительно сияет в сочетании с этим оператором:

(<*>) :: Applicative f => f (a -> b) -> f a -> f b

Тип говорит вам, что делает <*>: это функциональное приложение lifted для приложения. Вот несколько отдельных примеров:

Just not <*> Just True    ==> Just False
Nothing  <*> Just True    ==> Nothing
Just not <*> Nothing      ==> Nothing
[(+1),(*2)] <*> [10,100]  ==> [11,101,20,200]

Настоящая магия начинается, когда мы объединяем <$> и <*>: так можно поднять в аппликативный контекст функцию с любым количеством аргументов.

say :: String -> Int -> String -> String
say x i y = x ++ " has " ++ show i ++ " " ++ y
say <$> Just "haskell" <*> Just 99 <*> Just "operators"
  ==> Just "haskell has 99 operators"
say <$> Nothing <*> Just 99 <*> Just "operators"
  ==> Nothing
say <$> ["bob","jake"] <*> [2,3] <*> ["bananas","cars"]
  ==> ["bob has 2 bananas",
       "bob has 2 cars",
       "bob has 3 bananas",
       "bob has 3 cars",
       "jake has 2 bananas",
       "jake has 2 cars",
       "jake has 3 bananas",
       "jake has 3 cars"]

Что здесь происходит? Давайте пройдем через оценку. Ключ в том, что каждый <*> частично применяет еще один аргумент к функции.

    say <$> Just "haskell" <*> Just 99 <*> Just "operators"
=== ((say <$> Just "haskell") <*> Just 99) <*> Just "operators"
=== (fmap say (Just "haskell") <*> Just 99) <*> Just "operators"
==> (Just (say "haskell") <*> Just 99) <*> Just "operators"
==> Just (say "haskell" 99) <*> Just "operators"
==> Just (say "haskell" 99 "operators")
==> Just "haskell has 99 operators"

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

say <$> Just "haskell"                                  :: Maybe (Int -> String -> String)
say <$> Just "haskell" <*> Just 99                      :: Maybe (       String -> String)
say <$> Just "haskell" <*> Just 99 <*> Just "operators" :: Maybe (                 String)

Следующие два оператора немного проще:

(*>) :: Applicative f => f a -> f b -> f b
x *> y = liftA2 (\a b -> b) x y

(<*) :: Applicative f => f a -> f b -> f a
x <* y = liftA2 (\a b -> a) x y

Вы можете сравнить типы с более знакомым оператором:

(>>) :: Monad m => m a -> m b -> m b

Операторы <* и *> имеют в виду: выполняют обе эти операции, но сохраняют только один результат. Стрелка указывает на результат, который сохраняется:

Just 1 *> Just 2  ==> Just 2
Just 1 <* Just 2  ==> Just 1
Just 1 <* Nothing ==> Nothing
Nothing <* Just 2 ==> Nothing

Эти операторы могут показаться тривиальными, но они полезны при объединении проверок. Например:

decrease :: Int -> Maybe Int
decrease i = if i>0 then Just (i-1) else Nothing

small :: Int -> Maybe Int
small i = if i<10 then Just i else Nothing

decreaseSmall :: Int -> Maybe Int
-- do what decrease does, but fail if small fails
decreaseSmall i = decrease i <* small i
decreaseSmall 4   ==> Just 3
decreaseSmall 0   ==> Nothing
decreaseSmall 11  ==> Nothing

Теперь, когда мы увидели всех этих операторов, мы можем понять полное определение Applicative. Все операторы имеют определения в терминах liftA2, поэтому достаточно определить liftA2 и pure при реализации экземпляра Applicative.

class Functor f => Applicative f where
  pure :: a -> f a
  liftA2 :: (a -> b -> c) -> f a -> f b -> f c
  (<*>) :: f (a -> b) -> f a -> f b
  (*>) :: f a -> f b -> f b
  (<*) :: f a -> f b -> f a

15.4 Аппликативная валидация

Давайте посмотрим на приложение, которое немного интереснее, чем «Может быть» или списки. Часто в программировании нам нужны validate некоторые входы от пользователя. В этих случаях полезно собрать все ошибки, которые могут быть у входа. Файл exercises/Examples/Validation.hs реализует тип данных Validation:

data Validation a = Ok a | Errors [String]
  deriving (Show,Eq)

Пример Applicative для Validation работает следующим образом:

liftA2 (+) (Ok 1) (Ok 2)
  ==> Ok 3
liftA2 (+) (Errors ["oh no"]) (Errors ["boom"])
  ==> Errors ["oh no","boom"]

Обратите внимание, что в отличие от Maybe Applicative, у нас есть много различных типов отказов.

Вот пример, который вводит некоторых помощников, а затем использует их, чтобы поздравить кого-то с днем рождения:

invalid :: String -> Validation a
invalid err = Errors [err]

check :: Bool -> String -> a -> Validation a
check b err x
  | b = pure x
  | otherwise = invalid err

birthday :: String -> Int -> Validation String
birthday name age = liftA2 congratulate checkedName checkedAge
  where checkedName = check (length name < 10) "Name too long" name
        checkedAge = check (age < 99) "Too old" age
        congratulate n a = "Happy "++show a++"th birthday "++n++"!"
birthday "Guy" 31
  ==> Ok "Happy 31th birthday Guy!"
birthday "Guybrush Threepwood" 31
  ==> Errors ["Name too long"]
birthday "Yog-sothoth" 10000
  ==> Errors ["Name too long","Too old"]

Вот примеры Functor и Applicative для Validation:

instance Functor Validation where
  fmap f (Ok x) = Ok (f x)
  fmap _ (Errors e) = Errors e

instance Applicative Validation where
  pure x = Ok x
  liftA2 f (Ok x)      (Ok y)      = Ok (f x y)
  liftA2 f (Errors e1) (Ok y)      = Errors e1
  liftA2 f (Ok x)      (Errors e2) = Errors e2
  liftA2 f (Errors e1) (Errors e2) = Errors (e1++e2)

Определение liftA2 для Validation показывает, что ошибки собираются вместе слева направо. Это можно увидеть в приведенном выше примере, где выражение liftA2 congratulate checkedName checkedAge выводит ошибку ("Name too long") из checkedName первой, а ошибку ("Too old") из checkedAge последней.

15.5 Проверка списков: traverse

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

Давайте рассмотрим некоторые способы реализации такой функции:

allPositive [1,2,3]
  ==> Ok [1,2,3]
allPositive [1,2,3,-4]
  ==> Errors ["Not positive: -4"]
allPositive [1,-2,3,-4]
  ==> Errors ["Not positive: -2","Not positive: -4"]

Как всегда, при работе со списками, сопоставление шаблонов и рекурсия, как правило, идут своим чередом. Вот рекурсивное решение:

allPositive :: [Int] -> Validation [Int]
allPositive [] = Ok []
allPositive (x:xs) = liftA2 (:) checkThis checkRest
  where checkThis = check (x>=0) ("Not positive: "++show x) x
        checkRest = allPositive xs

Это немного хлопотно, чтобы всегда излагать рекурсию, как это. Если бы мы работали в Monad, мы могли бы просто использовать помощника, такого как mapM:

mapM (\x -> if x>=0 then Just x else Nothing) [1,2,3]
  ==> Just [1,2,3]
mapM (\x -> if x>=0 then Just x else Nothing) [1,2,3,-4]
  ==> Nothing

Аналог mapM для Applicative называется traverse и является методом класса типов Traversable:

traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)

Это одна чертова подпись типа, поэтому давайте немного упростим ее. Списки Traversable, поэтому мы можем специализировать этот тип на:

traverse :: Applicative f => (a -> f b) -> [a] -> f [b]

Похоже на то, что нам нужно! Для аппликантов, которые также являются монадами, traverse - это просто еще одно название для mapM:

traverse (\x -> if x>=0 then Just x else Nothing) [1,2,3]
  ==> Just [1,2,3]
traverse (\x -> if x>=0 then Just x else Nothing) [1,2,3,-4]
  ==> Nothing

Но для нашей Validation, которая не является Monad, traverse - это именно то, что мы хотим:

allPositive :: [Int] -> Validation [Int]
allPositive xs = traverse checkNumber xs
  where checkNumber x = check (x>=0) ("Not positive: "++show x) x
allPositive [1,2,3]
  ==> Ok [1,2,3]
allPositive [1,2,3,-4]
  ==> Errors ["Not positive: -4"]
allPositive [1,-2,3,-4]
  ==> Errors ["Not positive: -2","Not positive: -4"]

Обратите внимание, как traverse для Validation собирает все ошибки вместе, в порядке их возникновения в исходном списке.

PS. Фактически, Validation является одним из немногих примеров Applicative, который не может быть Monad. Ты можешь понять почему?

15.6 Примечание: Traversable

Что такое Traversable? Много знакомых структур. Вот несколько примеров:

decrease :: Int -> Maybe Int
decrease i = if i>0 then Just (i-1) else Nothing
-- Lists are Traversable
traverse decrease [1,2,3] ==> Just [0,1,2]
traverse decrease [1,0,3] ==> Nothing

-- Arrays are Traversable
traverse decrease (array (1,3) [(1,10),(2,11),(3,12)])
         ==> Just (array (1,3) [(1,9),(2,10),(3,11)])

-- Maps are Traversable
traverse decrease (M.fromList [("a",1),("b",2)])
         ==> Just (M.fromList [("a",0),("b",1)])
traverse decrease (M.fromList [("a",1),("b",0)])
         ==> Nothing

-- Either is Traversable
traverse decrease (Left "abc") ==> Just (Left "abc")
traverse decrease (Right 3)    ==> Just (Right 2)
traverse decrease (Right 0)    ==> Nothing

Итак, Traversable — класс типов для разнообразных контейнеров, подобный Foldable. Из определения видно, что Traversable является подклассом Foldable, а traverse и mapM — его методы.

class (Functor t, Foldable t) => Traversable t where
  traverse :: Applicative f => (a -> f b) -> t a -> f (t b)
  mapM :: Monad m => (a -> m b) -> t a -> m (t b)

Может быть трудно держать типы прямо здесь. Вернемся к типу traverse:

traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)

Здесь у нас есть два функтора: t и f. Функтор t также является Foldable и Traversable, а функтор f также является Applicative. Функция traverse позволяет запускать f -операции внутри контейнера t.

Не волнуйтесь, если это кажется абстрактным. На практике вы почти всегда используете traverse в списках.

15.7 Обработка ошибок: Alternative

Если вы немного поиграете с приложениями, вы начнете замечать некоторые ограничения их силы. Например, при написании парсеров, как мы сделали в примере parseMoney, было бы неплохо попробовать несколько разных парсеров и взять любой результат без сбоев. Это достаточно легко написать для конкретного приложения, такого как Maybe.

data Answer = Yes | No
  deriving (Show, Eq)

parseYes :: String -> Maybe Answer
parseYes "y" = Just Yes
parseYes "yes" = Just Yes
parseYes "maybe" = Just Yes
parseYes _ = Nothing

parseNo :: String -> Maybe Answer
parseNo "n" = Just No
parseNo "no" = Just No
parseNo "maybe" = Just No
parseNo _ = Nothing

eitherOf :: Maybe x -> Maybe x -> Maybe x
eitherOf (Just x) _  = Just x
eitherOf Nothing  mx = mx

parseAnswer :: String -> Maybe Answer
-- prefer positive answers!
parseAnswer s = eitherOf (parseYes s) (parseNo s)
parseAnswer "yes"    ==> Just Yes
parseAnswer "y"      ==> Just Yes
parseAnswer "n"      ==> Just No
parseAnswer "maybe"  ==> Just Yes
parseAnswer "x"      ==> Nothing

Как обобщить eitherOf? Нельзя просто дать ей тип Applicative f => f x -> f x -> f x: тогда реализация должна была бы выглядеть примерно как eitherOf a b = liftA2 something a b, а выражение eitherOf Nothing (Just x) вернуло бы Nothing — именно так работает экземпляр Applicative.

Оказывается, нужен ещё один класс типов — Alternative. Он добавляет к Applicative две операции: empty обозначает отсутствие результатов, а <|> объединяет результаты.

class Applicative f => Alternative f where
  empty :: f a
  (<|>) :: f a -> f a -> f a
  -- some other operations omitted

Теперь мы можем переписать наш код с помощью общих операций:

data Answer = Yes | No
  deriving (Show, Eq)

parseYes :: Alternative f => String -> f Answer
parseYes "y" = pure Yes
parseYes "yes" = pure Yes
parseYes "maybe" = pure Yes
parseYes _ = empty

parseNo :: Alternative f => String -> f Answer
parseNo "n" = pure No
parseNo "no" = pure No
parseNo "maybe" = pure No
parseNo _ = empty

parseAnswer :: Alternative f => String -> f Answer
parseAnswer s = parseYes s <|> parseNo s

Мы также можем выбрать, какой Alternative мы запускаем наш парсер, чтобы получить различное поведение. Maybe дает нам только один результат, а [] дает нам все возможные результаты.

> parseAnswer "yes" :: Maybe Answer
Just Yes
> parseAnswer "maybe" :: Maybe Answer
Just Yes
> parseAnswer "yes" :: [Answer]
[Yes]
> parseAnswer "maybe" :: [Answer]
[Yes,No]

Примеры Alternative для [] и Maybe неудивительны:

instance Alternative [] where
  empty = []
  (<|>) = (++)

instance Alternative Maybe where
  empty = Nothing
  Just x  <|> _  = Just x
  Nothing <|> mx = mx

Тип Validation также является Alternative. Этот экземпляр собирает вместе все сообщения об ошибках, как и экземпляр Applicative.

instance Alternative Validation where
  empty = Errors []
  Ok x <|> _ = Ok x
  Errors e1 <|> Ok y = Ok y
  Errors e1 <|> Errors e2 = Errors (e1++e2)

Вот последний пример: проверка контактной информации, которая является либо номером телефона, либо адресом электронной почты.

data ContactInfo = Email String | Phone String
  deriving Show

validateEmail :: String -> Validation ContactInfo
validateEmail s = check (elem '@' s) "Not an email: should contain a @" (Email s)

checkLength :: String -> Validation ContactInfo
checkLength s = check (length s <= 10) "Not a phone number: should be at most 10 digits" (Phone s)

checkDigits :: String -> Validation ContactInfo
checkDigits s = check (all isDigit s) "Not a phone number: should be all numbers" (Phone s)

validatePhone :: String -> Validation ContactInfo
validatePhone s = checkDigits s *> checkLength s

validateContactInfo :: String -> Validation ContactInfo
validateContactInfo s = validateEmail s <|> validatePhone s
validateContactInfo "[email protected]"
  ==> Ok (Email "[email protected]")
validateContactInfo "01234"
  ==> Ok (Phone "01234")
validateContactInfo "01234567890"
  ==> Errors ["Not an email: should contain a @","Not a phone number: should be at most 10 digits"]
validateContactInfo "01234567890x"
  ==> Errors ["Not an email: should contain a @",
              "Not a phone number: should be all numbers",
              "Not a phone number: should be at most 10 digits"]
validateContactInfo "x"
  ==> Errors ["Not an email: should contain a @",
              "Not a phone number: should be all numbers"]

Обратите внимание, что здесь, как и в предыдущих примерах, ошибки собираются слева направо: ошибки от validateEmail приходят раньше ошибок от validatePhone. Ошибка от checkDigits предшествует ошибке от checkLength.

15.8 Примечание: аппликативные функторы в контексте

15.8.1 Зачем нужны аппликативные функторы?

Аппликативные функторы стоит изучить, хотя по выразительной силе они не превосходят монады. Во-первых, как обсуждалось в лекции 13, стандартная библиотека GHC требует, чтобы у каждой монады был экземпляр Applicative. Поэтому программист на Haskell неизбежно будет часто встречать аппликативный код.

Во-вторых, вы столкнетесь с операторами приложений даже в коде Monadic. Такие выражения, как f <$> x <*> y <*> z, могут быть полезны во многих монадических контекстах. Кроме того, поскольку класс типа Traversable построен с точки зрения Applicative, вы часто будете использовать аппликативные операции с ним.

В-третьих, приложения являются отличным упражнением в понимании функциональных шаблонов дизайна. Они сочетают модель Функтора с моделью Моноида, и Альтернатива привносит еще одно измерение, подобное Моноиду. Умение эффективно работать с приложениями облегчит работу с дополнительными абстракциями, такими как трансформаторы monad или lenses.

Наконец, существуют типы, являющиеся аппликативными функторами, но не монадами. Практический пример — Validation. Без понимания аппликативов мы не смогли бы распознавать и обобщать операции над такими типами. Другой пример — ZipList.

15.8.2 Аппликативные функторы на практике

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

Такая же идея, как и у нашего приложения Validation, реализована в библиотеках validation и either.

Существует несколько парсерных библиотек, которые используют приложения. Например, regex-applicative, optparse-applicative, yamlparse-applicative, json-stream и так далее.

15.8.3 Монады и аппликативные функторы

Какова связь между монадой и аппликацией? Если заявитель также является монадой, действуют следующие законы:

pure             === return

fmap             === liftM

fmap f op        === do x <- op
                        return (f x)

liftA2           === liftM2

liftA2 f op1 op2 === do x <- op1
                        y <- op2
                        return (f x y)

op1 *> op2       === op1 >> op2

op1 <*> op2      === do f <- op1
                        x <- op2
                        return (f x)

При работе в Monad можно свободно сочетать операции Applicative и Functor с операциями Monad. Для примера перепишем mapM, оставив только аппликативные операции, и тем самым получим реализацию traverse. Начнём с такого определения:

myMapM op [] = return []
myMapM op (x:xs) = do y <- op x
                      ys <- myMapM op xs
                      return (y:ys)

GHCi говорит нам, что это работает только для монад:

Prelude> :t myMapM
myMapM :: Monad m => (a -> m b) -> [a] -> m [b]

Применим приведённые выше равенства pure === return и liftA2:

myMapM op [] = pure []
myMapM op (x:xs) = liftA2 (:) (op x) (myMapM op xs)

Готово! Теперь myMapM работает для любого Applicative:

Prelude> :t myMapM
myMapM :: Applicative f => (a -> f b) -> [a] -> f [b]

15.9 Викторина

Какой тип x в liftA2 (&&) Nothing x?

  1. Applicative f => f Bool
  2. Applicative Bool
  3. Maybe Bool

Сколько элементов у liftA2 f xs ys, когда xs и ys являются списками?

  1. length xs + length ys
  2. length xs * length ys
  3. min (length xs) (length ys)

Какое из этих выражений эквивалентно liftA2 f x y? Может быть несколько правильных ответов.

  1. f <$> x <*> y
  2. f <*> x <*> y
  3. f <*> x <$> y
  4. fmap f x <*> y
  5. pure f <*> x <*> y

Если f :: a -> Maybe b и xs :: [a], то какое из этих выражений имеет тип, отличный от других?

  1. fmap f xs
  2. traverse f xs
  3. map f xs

Для чего Applicative выдают разные результаты выражения pure x <* pure y и pure x <|> pure y?

  1. Maybe
  2. []
  3. Validation

15.10 Упражнения

16 Лекция 16: Разные темы

Эта заключительная лекция будет посвящена некоторым второстепенным темам, которые больше нигде не вписываются. Вы закончили все сложные части курса. Теперь пришло время расслабиться и насладиться классным Хаскелом!

16.1 Тестирование с помощью QuickCheck

Одно из преимуществ чистоты состоит в том, что чистые функции легко тестировать: не нужно настраивать глобальное состояние — достаточно передать аргументы и проверить результат. В этом разделе мы кратко познакомимся с библиотекой для тестирования на основе свойств (property-based testing) QuickCheck. Она же используется для проверки решений упражнений этого курса.

Рассмотрим тестирование ошибочной реализации reverse. Этот и следующие примеры находятся в файле exercises/Examples/QuickCheck.hs.

rev :: [a] -> [a]
rev (x:y:xs) = y:x:xs
rev xs = xs

Мы можем написать индивидуальный тест-кейс с помощью оператора === от QuickCheck:

(===) :: (Eq a, Show a) => a -> a -> Property
propRevSmall :: Property
propRevSmall = rev [1,2] === [2,1]

Мы можем попросить QuickCheck запустить их в GHCi:

*Examples.QuickCheck> quickCheck propRevSmall
+++ OK, passed 1 test.

Пока всё неплохо, но QuickCheck создавался не только для этого. Он предназначен для тестирования на основе свойств: вы задаёте свойство программы, а QuickCheck многократно запускает код со случайными входными данными и проверяет это свойство. Какое простое свойство есть у корректной реализации reverse? Если перевернуть список дважды, получится исходный список. Запишем это:

propRevTwice :: [Int] -> Property
propRevTwice xs = rev (rev xs) === xs

Наш Property имеет аргумент, который означает, что QuickCheck будет генерировать случайные значения и запускать тест. Мы можем использовать функцию verboseCheck, чтобы увидеть, какие значения выполняются. Мы также можем сами дать параметр тесту, если хотим проверить конкретное значение.

*Examples.QuickCheck> quickCheck propRevTwice
+++ OK, passed 100 tests.
*Examples.QuickCheck> verboseCheck propRevTwice
Passed:
[]
[] == []

Passed:
[1]
[1] == [1]

Passed:
[-2,1,-1]
[-2,1,-1] == [-2,1,-1]
-- lots of output
+++ OK, passed 100 tests.
*Examples.QuickCheck> quickCheck (propRevTwice [1,2,3])
+++ OK, passed 1 test.

Даже это свойство не уловило ошибку в нашей реализации. Давайте попробуем еще один. Вот пример того, как ведет себя rev (xs ++ ys). Возможно, вам понадобится минутка, чтобы убедить себя, что он должен поддерживать правильную функцию rev.

propRevTwo :: [Int] -> [Int] -> Property
propRevTwo xs ys = rev (xs ++ ys) === rev ys ++ rev xs

Посмотрим, будет ли это способствовать нашей реализации:

*Examples.QuickCheck> quickCheck propRevTwo
*** Failed! Falsified (after 5 tests and 3 shrinks):
[0,0]
[1]
[0,1,0] /= [1,0,0]

Наконец, провал! Здесь есть что распаковать. Во-первых, QuickCheck сообщает нам аргументы, с которыми имущество не удалось: это [0,0] и [1]. Мы можем проверить это сами:

*Examples.QuickCheck> quickCheck (propRevTwo [0,0] [1])
*** Failed! Falsified (after 1 test):
[0,1,0] /= [1,0,0]

Что означает «после 5 тестов и 3 сокращений»? Одна из интересных вещей в QuickCheck заключается в том, что, когда он находит неудачу, он пробует некоторые связанные значения, чтобы найти более приятный, меньший провал. Мы можем увидеть это в действии с verboseShrinking, который распечатывает все сбои, через которые проходит QuickCheck:

*Examples.QuickCheck> quickCheck (verboseShrinking propRevTwo)
Failed:
[4,-1,-4]
[1,4]
[-1,-4,1,4,4] /= [4,1,-1,-4,4]

Failed:
[-1,-4]
[1,4]
[-4,1,4,-1] /= [4,1,-4,-1]

Failed:
[-4]
[1,4]
[1,4,-4] /= [4,1,-4]

Failed:
[4]
[1,4]
[1,4,4] /= [4,1,4]

Failed:
[0]
[1,4]
[1,4,0] /= [4,1,0]

Failed:
[0]
[0,4]
[0,4,0] /= [4,0,0]

Failed:
[0]
[0,2]
[0,2,0] /= [2,0,0]

Failed:
[0]
[0,1]
[0,1,0] /= [1,0,0]

QuickCheck прошел путь от контрпримера [4,1,-1,4,4] до [1,0,0]. Очень мило!

16.1.1 Модификаторы

Иногда вам нужно ограничить значения, генерируемые QuickCheck. Ваша функция может работать не на всех входах, например? Давайте попробуем написать тест для last.

propLast :: [Int] -> Property
propLast xs = last xs === head (reverse xs)
*Examples.QuickCheck> quickCheck propLast
*** Failed! Exception: 'Prelude.last: empty list' (after 1 test):
[]

В этом случае мы можем исправить тест, просто перейдя на другой тип ввода. QuickCheck определяет тип NonEmptyList (не путать с Data.List.NonEmpty!), который является просто оберткой для обычного списка. Однако при генерации значений NonEmptyList QuickCheck не будет генерировать пустые списки.

newtype NonEmptyList a = NonEmpty [a]
propLastFixed :: NonEmptyList Int -> Property
propLastFixed (NonEmpty xs) = last xs === head (reverse xs)
*Examples.QuickCheck> quickCheck propLastFixed
+++ OK, passed 100 tests.

Существуют другие модификаторы: например, Positive для положительных чисел, NonNegative для неотрицательных или SortedList для отсортированных списков. Ниже приведён более сложный тест: он проверяет корректность n-го элемента cycle xs. Оба модификатора необходимы, поскольку !! не принимает отрицательные индексы, а cycle [] завершается ошибкой.

propCycle :: NonEmptyList Int -> NonNegative Int -> Property
propCycle (NonEmpty xs) (NonNegative n) =
  cycle xs !! n === xs !! (mod n (length xs))

16.1.2 Генераторы и forAll

Иногда нам нужно еще больше ограничить диапазон входных данных для тестирования. В качестве простого примера, вот тест, который Data.Char.toUpper меняет переданный ему персонаж:

propToUpperChanges :: Char -> Property
propToUpperChanges c = toUpper c =/= c
quickCheck propToUpperChanges
*** Failed! Falsified (after 1 test and 1 shrink):
'A'
'A' == 'A'

Конечно, он меняет только буквы lowercase. Как мы можем написать тест для этого? Не существует модификатора Lowercase, который работал бы как Positive или NonEmptyList. Мы должны вернуться к явной генерации значений с использованием forAll:

propToUpperChangesLetter :: Property
propToUpperChangesLetter = forAll (elements ['a'..'z']) propToUpperChanges
*Examples.QuickCheck> verboseCheck propToUpperChangesLetter
Passed:
's'
'S' /= 's'

Passed:
'z'
'Z' /= 'z'
-- lots of output omitted
+++ OK, passed 100 tests.

Отлично! Давайте посмотрим на типы, чтобы увидеть, что здесь происходит.

elements :: [a] -> Gen a
elements ['a'..'z'] :: Gen Char
forAll :: (Show a, Testable prop) => Gen a -> (a -> prop) -> Property
forAll (elements ['a'..'z']) :: Testable prop => (Char -> prop) -> Property

Здесь есть несколько новых типов. Значение типа Gen a является генератором для значений типа a. В следующем разделе мы поговорим немного больше об Gen, но в этом разделе вы увидите несколько функций, которые возвращают Gen, чтобы мы могли использовать их с forAll. Функция elements является генератором, который возвращает один из элементов данного списка наугад, как вы могли догадаться.

Класс типа Testable такой же, как и функция quickCheck. Существует так, что quickCheck может тестировать такие типы, как [Int] -> Bool -> Property, в дополнение к простым значениям Property.

quickCheck :: Testable prop => prop -> IO ()

Кроме того, некоторые простые типы, такие как Bool, имеют экземпляр Testable, так что вы можете писать тесты с использованием обычных предикатов Haskell вместо ===:

listHasZero :: [Int] -> Bool
listHasZero xs = elem 0 xs
*Examples.QuickCheck> quickCheck (listHasZero [1,0,2])
+++ OK, passed 1 test.
*Examples.QuickCheck> quickCheck listHasZero
*** Failed! Falsified (after 1 test):
[]

Возвращаясь к forAll, мы можем использовать forAll для написания более сложных тестов. Вот тест, который проверяет, что sort xs имеет те же элементы, что и xs. Обратите внимание, как мы используем NonEmptyList, чтобы гарантировать, что forAll имеет некоторые элементы для выбора.

propSort :: NonEmptyList Int -> Property
propSort (NonEmpty xs) =
  forAll (elements xs) (\x -> elem x (sort xs))

16.1.3 Дополнительно с QuickCheck

Мы только что поцарапали поверхность QuickCheck. Вот некоторые указания на некоторые вещи, которые вы найдете полезными, когда начнете писать более крупные тесты QuickCheck.

Иногда результаты QuickCheck недостаточно многословны. Вы можете добавить свои собственные линии на выход, используя комбинатор counterexample:

counterexample :: Testable prop => String -> prop -> Property

В качестве примера добавим запись входа в rev в propRevTwo:

propRevTwo' :: [Int] -> [Int] -> Property
propRevTwo' xs ys =
  let input = xs ++ ys
  in counterexample ("Input: " ++ show input) $
     rev input === rev ys ++ rev xs
*Examples.QuickCheck> quickCheck propRevTwo'
*** Failed! Falsified (after 4 tests and 5 shrinks):
[0]
[0,1]
Input: [0,0,1]
[0,1,0] /= [1,0,0]

Как вы уже догадались, Gen является Monad. Вы можете написать свои собственные генераторы, объединив генераторы, определенные QuickCheck. Вы можете проверить выход генераторов с помощью sample.

someLetters :: Gen String
someLetters = do
  c <- elements "xyzw"
  n <- choose (1,10)
  return (replicate n c)
*Examples.QuickCheck> sample someLetters
"yyyyyyyy"
"zzzzzzzzz"
"xxxxxxxxx"
"yyyyyyy"
"yyy"
"ww"
"xxxxxx"
"yyy"
"yyyyyyy"
"xxxxxxxxxx"
"y"

Близко к генераторам относится класс типа Arbitrary. Arbitrary - это то, как QuickCheck автоматически генерирует все эти входные данные.

class Arbitrary a where
  arbitrary :: Gen a
  shrink :: a -> [a]

Если вы пишете тесты для пользовательских типов, вам нужно либо использовать forAll, либо реализовать экземпляр Arbitrary. Вот что происходит, если вы упускаете один случай:

data Switch = On | Off
  deriving (Show, Eq)

toggle :: Switch -> Switch
toggle On = Off
toggle Off = On

propToggleTwice :: Switch -> Property
propToggleTwice s = s === toggle (toggle s)
*Examples.QuickCheck> quickCheck propToggleTwice
error:
No instance for (Arbitrary Switch)
        arising from a use of ‘quickCheck’
In the expression: quickCheck propToggleTwice

Вот два способа исправить это:

*Examples.QuickCheck> quickCheck (forAll (elements [On,Off]) propToggleTwice)
+++ OK, passed 100 tests.
instance Arbitrary Switch where
  arbitrary = elements [On,Off]

16.2 Фантомные типы

Бу! В системе типов есть призрак! Давайте посмотрим, что phantom Types может сделать для вас.

Фантомные типы — это типы, которые не принимают никаких значений. Они связаны с новыми типами (см. лекцию 10), поскольку оба являются способом добавления дополнительной проверки типа, не влияя на оценку программы вообще.

Давайте используем фантомные типы, чтобы отслеживать, в какой валюте находится сумма денег. Мы определяем фантомные типы EUR и USD (обратите внимание, что у них нет конструкторов!) и параметризованный тип Money a, который не использует параметр типа a ни для чего. Затем можно определить две константы, одна в евро, а другая в долларах. Вы можете найти весь код из этого раздела в файле exercises/Examples/Phantom.hs.

data EUR
data USD
data Money currency = Money Double
  deriving Show

dollar :: Money USD
dollar = Money 1

twoEuros :: Money EUR
twoEuros = Money 2

Обратите внимание, что все работы по типу подписей для dollar и twoEuros выполняются здесь. Выражение Money 1 имеет полиморфный тип Money currency, если не ограничивать его более конкретным типом. Мы явно даем dollar и twoEuros более ограниченные типы. Это аналогично определению чего-то вроде one :: Int; one = 1, поскольку константа 1 имеет полиморфный тип Num p => p, но мы даем ему более ограниченный тип.

*Examples.Phantom> :t Money
Money :: Double -> Money currency
*Examples.Phantom> :t Money 1
Money 1 :: Money currency

Теперь, когда у нас есть некоторые константы, мы можем писать функции, которые работают на них. Начнем с функции scaleMoney, которая умножает сумму денег на число. Валюта остается постоянной. Здесь тоже работает подпись типа: без подписи типа Хаскелл вывел бы тип Double -> Money a -> Money b.

scaleMoney :: Double -> Money currency -> Money currency
scaleMoney factor (Money a) = Money (factor * a)
*Examples.Phantom> :t scaleMoney 3 twoEuros
scaleMoney 3 twoEuros :: Money EUR
*Examples.Phantom> :t scaleMoney 3 dollar
scaleMoney 3 dollar :: Money USD

Далее: добавление двух сумм в одной валюте. Мы получаем хорошую ошибку типа, если мы пытаемся добавить значения в двух разных валютах.

addMoney :: Money currency -> Money currency -> Money currency
addMoney (Money a) (Money b) = Money (a+b)
*Examples.Phantom> :t addMoney dollar dollar
addMoney dollar dollar :: Money USD
*Examples.Phantom> :t addMoney twoEuros twoEuros
addMoney twoEuros twoEuros :: Money EUR
*Examples.Phantom> :t addMoney twoEuros dollar
error:
Couldn't match typeUSD’ with ‘EUR
      Expected type: Money EUR
        Actual type: Money USD
In the second argument of ‘addMoney’, namely ‘dollar’
      In the expression: addMoney twoEuros dollar

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

addMoneyUnsafe :: Money x -> Money y -> Money z
addMoneyUnsafe (Money a) (Money b) = Money (a+b)
*Examples.Phantom> addMoneyUnsafe twoEuros dollar
Money 3.0

Мы можем продолжать придерживаться этого подхода и определять конверсию валюты. Мы определяем тип Rate, который использует фантомные типы для отслеживания валют, которые он переводит. Типы convert и invert ограничены, чтобы иметь свойства, которые мы хотим. Существует также неограниченная версия функции преобразования, позволяющая сравнивать типы.

data Rate from to = Rate Double
  deriving Show

eurToUsd :: Rate EUR USD
eurToUsd = Rate 1.22

convert :: Rate from to -> Money from -> Money to
convert (Rate r) (Money a) = Money (r*a)

invert :: Rate from to -> Rate to from
invert (Rate r) = Rate (1/r)

convertUnsafe :: Rate from to -> Money x -> Money y
convertUnsafe (Rate r) (Money a) = Money (r*a)
*Examples.Phantom> convert eurToUsd twoEuros
Money 2.44
*Examples.Phantom> convert eurToUsd dollar
error:
Couldn't match typeUSD’ with ‘EUR
      Expected type: Money EUR
        Actual type: Money USD
In the second argument of ‘convert’, namely ‘dollar’
      In the expression: convert eurToUsd dollar
      In an equation for ‘it’: it = convert eurToUsd dollar
*Examples.Phantom> convert (invert eurToUsd) dollar
Money 0.819672131147541
*Examples.Phantom> convertUnsafe eurToUsd dollar
Money 1.22

Записка! Слова currency, from, to И так далее в предыдущих примерах Только переменные типа Ничего особенного с ними не происходит. Мы могли бы также дать invert Тип как Rate a b -> Rate b a без каких-либо изменений в типе безопасности.

Этот подход, использующий фантомные типы, имеет явные преимущества: дает нам ошибки типов для недействительного кода. Кроме того, по сравнению с определением множества конкретных типов, таких как data MoneyEur = MoneyEur Double, с фантомными типами нам нужно реализовать такие функции, как scaleMoney и addMoney только один раз. Кроме того, мы можем определить полиморфные и многоразовые концепции, такие как Rate. Вы можете сравнить этот подход с разделом 7.

Тем не менее, фантомные типы также имеют недостатки. Без продвинутых трюков мы не сможем справиться с валютами, которые определяются во время выполнения (например, чтение суммы от пользователя). Также легко оказаться в месте, где вам понадобятся языковые расширения, такие как Generalized Algebraic Datatypes, type family и другие type-level programming сборки. В конце концов, вы находитесь в мире dependent typing.

Итак, каковы хорошие приложения для фантомных типов? Когда вам нужно отследить некоторую простую, но важную информацию, это известно во время компиляции. Примером, который несколько лучше, чем валюты, является отслеживание того, были ли введены данные от пользователя для предотвращения атак, таких как SQL injection или cross-site scripting.

Мы можем использовать типы Input Safe и Input Unsafe, чтобы отслеживать, безопасны ли строки для прохождения в базу данных или нет. Если наш модуль экспортирует только функцию makeInput, а не конструктор Input, система типа гарантирует, что любые входы должны пройти через функцию escapeInput в какой-то момент, прежде чем войти в функцию базы данных, такую как addForumComment.

data Safe
data Unsafe

data Input a = Input String

-- Public constructor function for Input, only allows constructing
-- Unsafe Inputs from Strings.
makeInput :: String -> Input Unsafe
makeInput xs = Input xs

-- Adds comment to the database.
addForumComment :: Input Safe -> IO Result
addForumComment = ...

-- We can combine inputs, but that won't change their safety
concatInputs :: Input a -> Input a -> Input a
concatInputs (Input xs) (Input ys) = Input (xs++ys)

-- Strip bad characters to turn an unsafe input safe
escapeInput :: Input Unsafe -> Input Safe
escapeInput (Input xs) = Input (filter (\c -> isAlpha c || isSpace c) xs)

16.3 Параллельность и конкурентность

16.3.1 Параллелизм

Одно из замечательных следствий чистоты — она сильно упрощает параллелизм (parallelism), то есть одновременное выполнение нескольких вычислений. Посмотрим, как это делается в Haskell. Сначала запустим новый сеанс GHCi с поддержкой параллельного выполнения:

$ stack ghci --ghci-options "+RTS -N"

Далее, давайте определим очень наивную версию функции Фибоначчи (помните лекцию 1?), включим статистику производительности с помощью :set +s и посмотрим, сколько времени требуется для вычисления пяти значений функции:

Prelude> fib 0 = 1; fib 1 = 1; fib n = fib (n-1) + fib (n-2)
Prelude> :set +s
Prelude> map fib [29,29,29,29,29]
[832040,832040,832040,832040,832040]
(7.54 secs, 2,440,860,632 bytes)

Теперь введем модуль Control.Parallel.Strategies определяет стратегии параллельного вычисления значений. Мы воспользуемся стратегией parList rseq, чтобы параллельно вычислить все элементы списка до WHNF.

Prelude> import Control.Parallel.Strategies
Prelude Control.Parallel.Strategies> withStrategy (parList rseq) (map fib [29,29,29,29,29])
[832040,832040,832040,832040,832040]
(4.80 secs, 488,531,384 bytes)

На двухъядерной машине из примера это почти вдвое быстрее. Особенно важно, что само вычисление (computation) (map fib ...) удалось определить независимо от стратегии вычисления (evaluation strategy) (parList rseq): мы отделили что вычислять от того, как вычислять.

16.3.2 Конкурентность

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

Помимо удобных средств параллелизма, Haskell хорошо поддерживает конкурентность с помощью потоков (threads). Поскольку конкурентность связана с побочными эффектами, конкурентные вычисления выполняются в монаде IO. Классический пример — два потока: один печатает последовательность букв A, другой — букв B. Вот как это выглядит в Haskell:

printA :: IO ()
printA = putStrLn (replicate 40 'A')

printB :: IO ()
printB = putStrLn (replicate 40 'B')

concurrency :: IO ()
concurrency = do
  forkIO printA
  forkIO printB
  return ()
Prelude Control.Concurrent> concurrency
AABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABB

Операция forkIO :: IO () -> IO ThreadId берет операцию ввода-вывода и начинает выполнять ее в фоновом режиме. Он производит ThreadId, который может использоваться, например, для завершения потока.

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

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

takeMVar :: MVar a -> IO a
putMVar :: MVar a -> a -> IO ()
newEmptyMVar :: IO (MVar a)
send :: [String] -> MVar String -> IO ()
send values var = mapM_ (putMVar var) values

receive :: MVar String -> IO ()
receive var = do val <- takeMVar var
                 print val
                 -- loop unless at last value
                 when (val/="end") (receive var)

concurrency2 :: IO ()
concurrency2 = do
  var <- newEmptyMVar
  forkIO (send ["hello","world","and","goodbye","end"] var)
  forkIO (receive var)
  return ()
Prelude Control.Concurrent Control.Monad> concurrency2
"hello"
"world"
"and"
"goodbye"
"end"

16.4 Упражнения

  • Set16a
  • Set16b Призрачные типы
  • Никаких упражнений для параллельного или параллельного Хаскелла, извините!

16.5 Куда двигаться дальше?

Поздравляю! Вы закончили этот курс по функциональному программированию в Haskell. Что дальше? Вы определенно знаете достаточно Хаскела, чтобы продолжать учиться самостоятельно. Онлайн-сообщество Haskell очень дружелюбно, и есть много сообщений в блогах и другого контента, объясняющего передовые методы и функции. Вы можете найти много интересных вещей, например:

Просто продолжайте писать Haskell, изучая вещи (например, библиотеки и инструменты), когда вы сталкиваетесь с ними. Многие вещи, которые вы изучаете с Haskell, могут быть переведены на другие языки, такие как TypeScript, Elm, Rust или F#.

Наконец, вот неполный список вещей, которые остались в стороне от этого курса, но стоит изучить:

  • Языковые особенности
  • Абстракции
    • Трансформаторы монады: RWH, Wikibook
    • Бесплатные монады (продвинутая тема): blog
    • Объективы (продвинутая тема): tutorial glassery
    • Bartosz Milewski освещает множество промежуточных и продвинутых тем в своем блоге.
  • Тоолинг
    • Использование Cabal и Stack для создания собственных проектов
    • Профиль: RWH, GHC
    • Hlint
  • Библиотеки
    • Парсек: RWH
    • Scotty (простой веб-фреймворк), Aeson (json): blog
    • Слуга (Интернет-фреймворк с фантомными типами)
  • Теория категорий
    • Многие абстракции Хаскелла основаны на теории категорий.
    • Теория категорий может стать ценным источником новых идей для программирования.
    • Теория категорий может показаться пугающей, поэтому хорошо знать, что вы можете прекрасно обходиться без нее.
    • У Бартоша Милевского есть много хорошего материала, например, Category Theory for Programmers
    • Haskell Wiki и Wikibook имеют разделы по теории категорий

16.6 Благодарности

Этот курс стал возможен благодаря Нитор Он пожертвовал на этот проект часы и часы рабочего времени Джоэла. Спасибо!

Спасибо всей команде Haskell MOOC, особенно

  • Джону Лонгу за помощь с материалом.
  • Антти Лааксонену за организацию курса и помощь в его организации.
  • Топи Талвити за проведение проверки инфраструктуры

Спасибо всем студентам, которые терпеливо ждали часть 2 и сообщали об ошибках в материале.&Упражнения!