learn haskell
25 subscribers
2 photos
2 links
изучение языка haskell
Download Telegram
EMACS как IDE

Emacs - хорошая операционная система, вот только редактор в ней подкачал.

Уже пару лет я использую doom emacs для ... практически всего что связано с разработкой и, естественно, для освоения haskell. С шуткой выше давно можно не согласиться (со второй частью), если использовать evil mode для emacs, который активирует vim-комбинации клавиш для редактирования текста и манипуляции с буферами.
Использовать emacs для написания кода - это сплошное удовольствие. Это не только продвинутый редактор, но и почти всё, что потребуется для разработки в одном флаконе - терминалы, справка, git-клиент, в нем можно открыть pdf или сайт как в браузере (без js и картинок, конечно).
Благодаря встроенным функциям и lsp сразу становится доступно множество функций по запуску тестов, переименованию в проекте, просмотру определений, навигации по проекту и т.д.

Чтобы настроить emacs для haskell у вас должен быть установлен hls (haskell language server).
Далее в файле init.el нужно раскомментировать строчку
(haskell +lsp +tree-sitter)    ; a language that's lazier than I am

она позволит использовать haskell-mode для редактирования файлов .hs.

Чтобы работать с комфортом нам понадобятся еще некоторые настройки - хуки для отображения сигнатуры типов и автоиндентации, а так же автоформатирование файла при сохранении:
(add-hook 'haskell-mode-hook 'turn-on-haskell-doc-mode)
(add-hook 'haskell-mode-hook 'turn-on-haskell-indentation)
(add-hook 'haskell-mode-hook #'lsp)
(add-hook 'haskell-literate-mode-hook #'lsp)
(setq haskell-stylish-on-save t)
👍2
Maybe

Это параметризованный тип, который представляет опциональное значение, то есть значение которое может отсутствовать. Такой тип решает проблем у отсутствующего значения в результате - когда вернуть что-то нужно, а вернуть нечего, например ключ отсутствует в коллекции или мы просим первый элемент в пустом списке.
Аналогами такого подхода является std::optional<T> в С++, Option<T> в rust и error-нотация в go.

Определение Maybe выглядит следующим образом:
data Maybe a = Nothing | Just a

то есть дословно - ничего или какое значение типа a.

Модуль Data.Maybe содержит функции isJust и isNothing для проверки значений в Maybe, однако проверить наличие значения лучше через старый-добрый паттерн матчинг:
haveSome :: Maybe a -> Bool
haveSome Nothing = False
haveSome (Just _) = True


Использование isJust и isNothing считается плохой практикой, из-то того, что это ближе к императивному стилю и отделяет проверку наличия значения от его извлечения.

Использование Maybe позволяет более идиоматично работать с отсутствующим значением и предохраняет от бесконечных самописных проверок на пустое значение.
1👍1
fixity

это свойство, которое определяет ассоциативность и порядок применения в haskell. Задание fixity позволяет компилятору правильно интерпретировать выражения без избыточных скобок.
Синтаксис объявления fixity:
infixl n `op`   -- левая ассоциативность, приоритет n
infixr n `op` -- правая ассоциативность, приоритет n
infix n `op` -- неассоциативный оператор, приоритет n


Всего существует 9 уровней приоритета операций, начиная с 0. 0 - это самый нисший приоритет. Еще есть приоритет 10 - применение функции (f(x)), больше ничего в него прописать нельзя, он виртуальный.
Приоритеты стандартных операторов подобраны так, чтобы выражения читались естественно. Узнать fixity оператора можно в ghci с помощью команды :i. По умолчанию все пользовательские операторы имеют infixl 9.

Для собственных операторов можно задать иное значение fixity чтобы повысить их приоритет или изменить ассоциативность:
module Main (main) where

infixl 6 <^.^>
(<^.^>) :: Int -> Int -> Int
a <^.^> b = a * b - 1

main :: IO ()
main = do
print $ show $ 2 <^.^> 3 + 2 -- = 7
print $ show $ 2 <^.^> (3 + 2) -- = 9
print $ show $ (2 <^.^> 3) + 2 -- = 7
👍4
IO

Чистые функции на то и чистые, что не имеют побочных эффектов. Но мы пишем программы именно ради побочных эффектов. Вывод чего-то на консоль, чтение или запись в файл, базу, сеть - всё это "грязные" операции, которые нам необходимы.
Haskell позволяет выполнять подобные манипуляции, но требует, чтобы они были помечены специально. Для этого существует параметризованный тип IO. Его можно сразу увидеть в функции main:
main :: IO ()
...


В данном случае IO параметризуется пустым значением - пустым кортежем. Вообще main не является функцией - она не возвращает значения, потому о main можно говорить как о действии ввода-вывода. Такими действиями являются не только main, но и, например, консольный ввод-вывод:
putStrLn :: String -> IO () -- выводит строку в консоль
getLine :: IO String -- читает строку из консоли


Для последовательного выполнения (не вычисления) действий (не функций) используется do-нотация. Она позволяет работать с IO типами, как с обычными типами:
someFunc :: String -> String
...

main :: IO ()
main = do
line <- getLine
let someValue = someFunc line


В примере выше мы выполняем действие чтения строки из консоли в переменную и далее передаём её в функцию someFunc. Но getLine возвращает IO String, а someFunc - чистая функция, которая принимает String. Проблема решается с помощью оператора <-, который извлекает значение из контекста IO и связывает его с именем переменной. Оператор <- работает только в блоке do.
👍21
return

В императивных языках программирования return прерывает выполнение текущей функции и возвращает из неё какое-то значение.
В haskell всё иначе. return - это функция и работает она совершенно иначе:
return :: Monad m => a -> m a


В haskell return ничего не прерывает, а просто создаёт действие ввода/вывода из чистого значения. Получается контекст, который инкапсулирует некоторое значение. То есть выполняет действие обратное оператору <-. Выражение return "someString" будет иметь тип IO String.
return чаще используется в блоке do для создания действий ввода/вывода, которые ничего не делают, или для того, чтобы блок do возвращал ненужное значение (в этом случае вызов return должен быть последним в блоке).
Например:
checkNumber :: Int -> IO ()
checkNumber x = do
if x > 0
then putStrLn "Положительное"
else return ()
putStrLn "Проверка завершена"

В коде выше обе ветки выражения if должны иметь один тип.

Примечание
В современном haskell return не используют. Вместо этого используют pure.
👍1
Консольный ввод-вывод

Полезные функции и действия консольного ввода-вывода из стандартной библиотеки:

putStr и putStrLn
putStr :: String -> IO ()
putStrLn :: String -> IO ()

Принимают строку как параметр и печатают её на экране. При этом putStrLn еще выполняет перевод строки.

putChar
putChar :: Char -> IO ()

Принимает символ и печатает его на экране. Функция putStr определена рекурсивно с помощью putChar.

print
print :: Show a => a -> IO ()

Принимает значение любого экземпляра класса типов Show, вызывает функцию show, чтобы получить строковое представление и выводит его на экран через putStrLn.
Функция print вызывается каждый раз, когда нужно отобразить результат вычислений в интерпретаторе ghci.

getContents
getContents :: IO String

Считывает ввод до тех пор, пока не дойдёт до символа конца файла или прерывания работы команды в терминале. getContents ленив и читает ввод по мере необходимости.

getLine и getChar
getLine :: IO String
getChar :: IO Char

getLine читает весь пользовательский ввод до символа переносы строки (\n). getChar читает один символ с консоли.

readLn
readLn :: Read a => IO a

Читает строку из консоли и преобразовывает в нужный тип - это комбинация двух действий getLine и read. При использовании readLn нужно явно указать тип (аннотация типа), который мы хотим в результате:
num <- readLn :: IO Int 

при этом может возникать ошибка парсинга (Runtime Exception), если требуемый тип не получается из строки. Чтобы такого не случалось лучше использовать readMaybe из модуля Text.Read, который не крашит программу в случае ошибки конвертации.

interact
interact :: (String -> String) -> IO ()

Это функция для потоковой обработки текста. Она берет весь ввод из stdin, применяет к нему чистую функцию и выводит результат в stdout. Является ленивой и выводит данные по мере их поступления.
👍1
Полезные функции для работы с действиями

when из Control.Monad
when :: Applicative f => Bool -> f () -> f ()

Она принимает булево значение и действие ввода-вывода, которое выполняется только если первый параметр True. Можно использовать как замену return () в выражении if:
input <- getLine
-- это
when (input == "someStr") $ do
putStrLn input
-- эквивалентно этому
if (input == "someStr")
then putStrLn input
else return ()


sequence
sequence :: (Traversable t, Monad m) => t (m a) -> m (t a)

Принимает последовательность действий ввода-вывода и возвращает одно действие ввода-вывода, последовательно выполняющее входные действия:
rs <– sequence [getLine, getLine, getLine]

создаст действие, которое выполнит getLine 3 раза.

mapM и mapM_
mapM :: (Traversable t, Monad m) => (a -> m b) -> t a -> m (t b)
mapM_ :: (Foldable t, Monad m) => (a -> m b) -> t a -> m ()

Функция mapM принимает функцию и список, применяет функцию к элементам списка, сводит элементы в одно действие ввода-вывода и выполняет их. Функция mapM_ работает так же, но отбрасывает результат действия ввода-вывода.

forever из Control.Monad
forever :: Applicative f => f a -> f b

создаёт бесконечный цикл (внутри там конечно же рекурсия) действий. Функция принимает одно действие и выполняет его снова и снова, пока программа не будет остановлена.

forM и forM_ из Control.Monad
forM :: (Traversable t, Monad m) => t a -> (a -> m b) -> m (t b)
forM_ :: (Foldable t, Monad m) => t a -> (a -> m b) -> m ()

похожи на mapM и mapM_, но параметры поменяны местами. Первый параметр это список, второй – это функция, которую надо применить к списку и затем свести действия из списка в одно действие.
Использование forM более похоже на использование цикла for в императивных языках:
forM [1, 2, 3] (\x -> putStrLn (show x))


replicateM и replicateM_ из Control.Monad
replicateM :: Applicative f => Int -> f a -> f [a]
replicateM_ :: Applicative f => Int -> f a -> f ()

предназначены для выполнения одного и того же действия (второй параметр) определённое число раз (первый параметр). Как и в прочих функциях выше _ в конце означает, что функция вернёт пустое действие (результат нам не нужен).
В replicateM список результатов строится лениво. Однако сами действия выполняются строго по порядку.
-- напечатать 5 случайных чисел до 100
numbers <- replicateM 5 (randomRIO (1, 100))
print numbers
-- напечатать 3 раза в консоль
replicateM_ 3 (putStrLn "Привет!")
👍2
cabal

Это пакетный менеджер и система сборки для haskell. Она помогает настраивать, собирать и устанавливать программное обеспечение на haskell, а также легко распространять его среди других пользователей и разработчиков.
cabal входит в состав ghcup.

Чтобы создать простое приложение с помощью cabal нужно набрать:
cabal init myapp --non-interactive

в результате будет создан каталог myapp:
└── myapp
├── app
│ └── Main.hs
├── CHANGELOG.md
└── myapp.cabal

С Main.hs всё понятно - это файл исходного кода с функцией main. Файл myapp.cabal содержит информацию о проекте (описание, версию, лицензию, автора и т.д.) и о сборке проекта:
cabal-version:      3.16 -- текущая версия cabal
name: myapp -- имя проекта
build-type: Simple -- тип сборки
extra-doc-files: CHANGELOG.md -- файл документации

common warnings -- именованная секция общих настроек
ghc-options: -Wall -Werror -- флаги сборки для компилятора

executable myapp -- секция исполняемого файла
import: warnings -- добавление именованной секции настроек
main-is: Main.hs -- файл, содержащий функцию main
build-depends: base ^>=4.22.0.0 -- зависимости проекта через запятую
, containers >= 0.6 && < 0.8
hs-source-dirs: app -- папки где искать исходники
default-language: Haskell2010 -- используемый стандарт языка


Для сборки проекта нужно ввести команду:
cabal build

для запуска:
cabal run myapp


Так как редактировать cabal-файл нужно ручками, то возникает потребность в его автоформатировании. Для этого существует утилита cabal-gild.
cabal-gild --input myapp.cabal --output myapp.cabal

Вызов такой команды отформатирует и перезапишет файл myapp.cabal.
👍2
Data.Text

Кроме типа String в haskell для работы со строками еще существует тип Text из модуля Data. В отличии от String, который является [Char], внутри Data.Text находится массив. Еще Data.Text не использует ленивые вычисления, а если они нужны, то есть тип Data.Text.Lazy.
Использование Data.Text более предпочтительно для решения реальных задач, чем String. Text лежит в памяти более компактно и итерировать по нему гораздо быстрее, чем по списку.

Модуль Data.Text предоставляет функции конвертации в String (однако применение их вычислительно дорого):
Data.Text.pack :: String -> Data.Text.Internal.Text
Data.Text.unpack :: Data.Text.Internal.Text -> String


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


Для того, чтобы не прописывать постоянно конвертацию строк вида "строка" в Data.Text нужно использовать расширение языка OverloadedString:
haskell
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.Text as Text

someText :: Text.Text
someText = "строка"`


Почти каждая функция для работы со String имеет свой аналог для Data.Text:
- lines - разбиение строки по символу переноса на [Text]
- unlines - объединение строки символом переноса
- words - разбиение строки по любым пробельным символам
- unwords - объединяет строку пробелами
- splitOn - разделяет текст по подстроке
- intercalate - обратная splitOn

Data.Text является экземпляром Semigroup и Monoid, потому для него можно использовать <> и mconcat для объединения строк.
👍1
Расширения

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

Есть три способа включить расширение:
1. В исходном файле в самом начале, даже перед строкой module ... нужно добавить директиву:
{-# LANGUAGE ViewPattern #-}
{-# LANGUAGE OverloadedStrings #-}

2. В файле проекта, например в файле .cabal в default-extensions::
library
default-extensions:
- OverloadedStrings
- ViewPattern

3. Через командную строку GHC
ghc -XOverloadedStrings Main.hs


Некоторые распространённые расширения
NoImplicitPrelude - отключает стандартный Prelude.
OverloadedStrings - позволяет использовать строковые литералы ("текст") как Text, ByteString или String в зависимости от контекста.
ViewPattern - позволяет использовать вызов функций в паттерн-матчинге.
DuplicateRecordFields - позволяет использовать одинаковые имена полей типа в рамках одного модуля для разных типов в синтаксисе записей.
OverloadedRecordDot - позволяет использовать точечную нотацию для доступа к полям класса (record.field).
Strict / StrictData - делает все поля данных строгими (энергичными) по умолчанию. Помогает избежать утечек памяти из-за ленивости.

Чтобы самые важные расширения были включены в проект нужно в .cabal файле указать самую свежую версию компилятора:
default-language: GHC2024
👍3
Ленивые вычисления

Вычисления в haskell являются ленивыми. Это значит что выражение не будет вычислено до того момента, пока не понадобится его результат.
Такой подход позволяет иногда сэкономить на вычислениях, но может перерасходовать память и увеличивать когнитивную сложность.
Например в энергичных (строгих, жадных) языках мы обязаны вычислить выражения-аргументы функции до передачи их в функцию, даже если они там реально не нужны.
В hakell не так,
wtfunction :: Int -> Int
wtfunction _ = 42

main :: IO ()
main = print . wtfunction $ div 2 0 -- делим на ноль

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

С точки зрения вычисления выражение проходит через 3 стадии:
1. невычисленное (thunk),
2. частично вычисленное (weak head normal form, whnf),
3. вычисленное (normal form, normal).
main :: IO ()
main =
let cx = 2 / 6.054 -- thunk
nk = 4 * 12.003 -- normal
coeffs = [cx, nk] --whnf
in print $ coeffs !! 1

В примере выше нам нужен только второй (с индексом 1) элемент списка, потому элемент 0 нам не нужен и он остаётся невычисленным, а сам список является частично вычисленным.

Лень также позволяет работать с бесконечными списками:
take 10 [2,4 ...]

Это выражение выдаст нам первые 10 чётных чисел без необходимости вычислять бесконечный список.

Однако ленивость вычислений может приводить к утечке пространства space leak. Это связано с тем, что невычисленные thunk нужно где-то хранить, пока они не понадобятся. Бороться с ней можно:
- оптимизацией компилятора (используем флаг -O2в ghc-options в файле настройки проекта)
- вручную, используя принудительное вычисление ($! или seq)
main = print . wtfunction $! div 2 0 -- падаем с divide by zero


или пометив поле типа или аргумент ! с расширением BangPatterns:
{-# LANGUAGE BangPatterns #-}
module Main (main) where

data Point = Point !Int !Int

main :: IO ()
main = print . length $ [x, undefined, undefined]
where !x = Point 2 (div 4 0) -- падаём


Также можно использовать расширение StrictData, делающее все поля строгими по умолчанию.
2👍1
Работа с текстовыми файлами

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

Выполнять простую работу с текстом в виде типа String можно функциями:
readFile :: FilePath -> IO String
writeFile :: FilePath -> String -> IO ()
appendFile :: FilePath -> String -> IO ()


Однако тип String ленив и не эффективен и почти всегда с текстом работают через Data.Text. Для файловой работы в модуле Data.Text.IO есть точно такие же функции:
module Main (main) where

import qualified Data.Text as Text
import qualified Data.Text.IO as TextIO

doSomeMagic :: Text.Text -> Text.Text
...

main :: IO ()
main = do
input <- TextIO.readFile "some.txt"
let result = doSomeMagic input
TextIO.putStrLn result


Работать так же можно с дескрипторами файлов IO Handle с помощью модуля System.IO, при этом можно указать больше параметров для работы с файлом (способ открытия, кодировка). Для этого существуют функции:
-- открыть файл с указанием режима
openFile :: FilePath -> IOMode -> IO Handle
-- закрыть файл
hClose :: IO.Handle -> IO ()
-- прочитать строку из файла
hGetLine :: IO.Handle -> IO String
-- читает весь файл в одну строку
hGetContents :: IO.Handle -> IO String
-- записать строку в файл
hPutStrLn :: IO.Handle -> String -> IO ()
-- установить кодировку файла
hSetEncoding :: IO.Handle -> IO.TextEncoding -> IO ()

где IOMode определяет какая работа будет выполняться с файлом:
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode


Однако ручное открытие-закрытие файла через openFile и hClose не безопасно - в случае ошибки или склероза файл может не быть закрытым. Для безопасной работы лучше использовать действие withFile из System.IO, которое откроет файл и закроет его, когда это будет нужно:
withFile :: FilePath -> IOMode -> (IO.Handle -> IO r) -> IO r


Пример:
import System.IO
import qualified Data.Text.IO as TextIO

main :: IO ()
main = do
withFile "input.txt" ReadMode $ \handle -> do
hSetEncoding handle utf8
content <- TextIO.hGetContents handle
putStrLn (show content)


Для работы с большими файлами (когда прочитать их целиком в Data.Text не получается) используют библиотеки потоковой обработки - conduit, pipes или streaming.
👍4
Работа с бинарными данными

Для работы с двоичными данными используется тип Data.ByteString. Внутри он представлен как простой массив байт.

Строковые литералы можно использовать как значения Data.ByteString, если включено расширение OverloadedString. Однако функции pack и unpack (из Data.ByteString) для этого не подходят, в отличии от аналогичных из Data.Text. Для такого нужны функции из Data.ByteString.Char8:
Data.ByteString.pack :: [GHC.Word.Word8] -> Data.ByteString.ByteString
Data.ByteString.unpack :: Data.ByteString.ByteString -> [GHC.Word.Word8]
Data.ByteString.Char8.pack :: String -> Data.ByteString.ByteString
Data.ByteString.Char8.unpack :: Data.ByteString.ByteString -> [Char]


Для простого чтения и записи бинарного файла целиком есть функции readFile и writeFile из Data.ByteString.Char8, которые работают аналогично их текстовым тёзкам.

Для ленивой работы с бинарными данными есть Data.ByteString.Lazy, который представлен как связаный список чанков (chunk) байт, которые заполняются при необходимости. Ленивый аналог позволяет работать потоково с большими файлами.
module Main (main) where

import qualified Data.ByteString.Lazy as BSL

countSome :: FilePath -> IO Int
countSome path = do
content <- BSL.readFile path
pure $ fromIntegral $ BSL.length $ BSL.filter (==6) content

main :: IO ()
main = do
let dataToFile = BSL.pack [1,2,3,6,4,5,6,6] -- представим, что это большой файл
let fileName = "test.bin"
BSL.writeFile fileName dataToFile
cnt <- countSome fileName
print cnt -- выведем 3
👍3
Functor

Это класс типов, позволяющий применять функции к значениям, находящимся внутри контекста (например IO или Maybe) или контейнера (например List или Map). Использование Functor позволяет обобщить функцию для для значения, которое может находится в разных контекстах. Иначе нам пришлось бы реализовывать какую-либо функцию для всех контекстах в которых может находится значение.
class Functor f where
fmap :: (a -> b) -> f a -> f b -- Minimal Complete Definition
(<$) :: a -> f b -> f a


Функция fmap принимает 2 аргумента - функцию преобразования из a в b и функтор, содержащий значение a, и возвращает тот же функтор со значением b. Этот новый функтор имеет точно такую же структуру (или форму), что и входной функтор.
Оператор <$ принимает значение типа a, значение типа b, упакованное в функтор f, и возвращает функтор f a. По сути, оператор упаковывает значение первого аргумента в контекст второго аргумента, отбрасывая второе значение.

Также доступен оператор-синоним:
(<$>) :: Functor f => (a -> b) -> f a -> f b
(<$>) = fmap


Пример:
module Main (main) where

incInt :: Int -> Int
incInt = (+1)

main :: IO ()
main = do
print (incInt <$> (Just 42)) -- Just 43
print (incInt <$> Nothing) -- Nothing
print (incInt <$> [1,2,3]) -- [2,3,4]
print (fmap incInt [1,2,3]) -- [2,3,4]


Все экземпляры класса Functor должны удовлетворять двум законам (Functor Laws):
1. Закон тождества
fmap id = id

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

2. Закон композиции
fmap (f . g) = fmap f . fmap g

Если две последовательные операции преобразования выполняются одна за другой с использованием двух функций, результат должен быть таким же, как при выполнении одной операции преобразования с использованием одной функции, что эквивалентно применению первой функции к результату второй.
👍2
Applicative
или Аппликативный Функтор

Класс типов Applicative расширяет возмжности Functor за счёт использования функций, которые находятся в контексте.
Ограничение Functor в том, что функция fmap принимает функцию от одного аргумента (a->b). Applicative снимает это ограничение, позволяя использовать частичное применение для функции в контексте. Для Functor такое не прокатывает, ему нужна функция вне контекста, но частичное применение на переменной в контексте даёт функцию в контексте:

ghci> :t (+) <$> Just 42
(+) <$> Just 42 :: Num a => Maybe (a -> a)


Описание выглядит так:
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
...


Функция pure берёт обычное значение и помещает его в контекст. Является современной заменой функции return. Она позволяет поместить функцию в контекст и использовать с <*>:
ghci> (6+) <$> Just 1
Just 7
ghci> pure (6+) <*> Just 1
Just 7


Используя оператор <*> (читается app) мы можем использовать функцию в контексте, которую получили частичным применением:
ghci> maybeInc = (+) <$> Just 1
ghci> maybeInc <*> Just 4
Just 5


Для аппликативов так же существуют свои законы:
1. Identity - pure id <*> v = v
2. Homomorphism - pure f <*> pure x = pure (f x)
3. Interchange - u <*> pure y = pure ($ y) <*> u
4. Composition - pure (.) <*> u <*> v <*> w = u <*> (v <*> w)

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

Списки

Списки, а точнее их конструктор ([]), являются экземпляром класса Applicative:
instance Applicative [] where
pure x = [x]
fs <*> xs = [f x | f <– fs, x <– xs]

При этом оператор <*> (ap), так как он реализован через генератор списков, выдаёт все возможные комбинации применения функций из списка fs к значениям в списке xs:
ghci> [(+1), (*10)] <*> [2,3]
[3,4,20,30]
ghci> [(+), (*)] <*> [2, 3] <*> [1, 2]
[3,4,4,5,2,4,3,6]


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

IO

Контекст ввода-вывода тоже аппликативный функтор:
instance Applicative IO where
pure = return
a <*> b = do
f <– a
x <– b
return (f x)


Тут сразу видно, что pure реализован через return, то есть pure это всего лишь return в аппликативном стиле. Так как a и b находяться в контексте IO, то чтобы извлечь их из этого контекста, его нужно выполнить. Для этого используется <- в блоке do:
ghci> pure (++) <*> getLine <*> getLine
aaa
bbb
"aaabbb"


Функции

Тип функции ((->)r) тоже является экземпляром Applicative:
instance Applicative ((–>) r) where
pure x = (\_ –> x)
f <*> g = \x –> f x (g x)


pure создаёт функцию, которая игнорирует свой аргумент (аргумент же должен быть) и всегда выдаёт значение x.
ghci> pure 42
42
ghci> pure 42 1
42


Вызов <*> с двумя функциями вернёт функцию, которая сначала применит функцию g к аргументу, а затем результат к f.
Для комбинации 2-х функций, первая становится константой и просто игнорирует первый аргумент (первый x f x (g x)) и применяет себя к результату g x. Для трёх немного сложнее, но смысл тот же. Каждое <*> возвращает функцию от 1 аргумента:
ghci> :t pure (+10) <*> (+1)
pure (+10) <*> (+1) :: Num a => a -> a
ghci> pure (+10) <*> (+1) $ 3 -- равносильно (3 + 1) + 10
14
ghci> pure (+) <*> (+10) <*> (+100) $ 3
116


ZipList

Это еще один способ для списков быть аппликативным функтором, но в отличии от простого применения каждой функции к каждому аргументу ZipList применять первую функцию к первому аргументу, вторую ко второму и так далее. При этом список результат будет иметь ту же длину, что и самый короткий из fs и xs.
instance Applicative ZipList where
pure x = ZipList (repeat x)
ZipList fs <*> ZipList xs = ZipList (zipWith (\f x –> f x) fs xs)


Пример:
module Main (main) where

import Control.Applicative

main :: IO ()
main = print $ getZipList $ ZipList [(+1), (+2), (+3)] <*> ZipList [1, 2, 3] -- [2,4,6]
👍3
Monad

Класс типов Monad расширяет возможности аппликативных функторов. Он добавляет операцию применения функции, которая принимает чистое значение и возвращает значение в контексте (a -> m b), к значению в контексте (m a). Таким образом Monad, собирая всё вместе, позволяет выполнять любые вычисления в нужном нам контексте.
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a

Минимально требуется определить оператор >>= (bind - связывание). Класс Monad так же добавляет монадическую функцию return. Так получилось из-за того, что исторически сначала появился класс Monad, а затем аппликативы и return оставлен для совместимости, но так будет не всегда, и использовать её не рекомендуется.

Monad позволяет сократить бесконечные ряды функций, которые занимаются сопоставлением с образцом (pattern matching) для проброса значение в контексте между функциями.
Например, если нам нужно транзитивно получить значение через две-три мапы, в которых может не быть этих значений:
module Main (main) where

import qualified Data.Map as Map

map1 :: Map.Map Int String
map1 = Map.fromList [(1, "str1"), (2, "str2")]

map2 :: Map.Map String Int
map2 = Map.fromList [("str1", 100), ("str3", 300)]

map3 :: Map.Map Int String
map3 = Map.fromList [(100, "otherString1"), (400, "otherString4")]

lookupMap1 :: Int -> Maybe String
lookupMap1 x = Map.lookup x map1

lookupMap2 :: String -> Maybe Int
lookupMap2 x = Map.lookup x map2

lookupMap3 :: Int -> Maybe String
lookupMap3 x = Map.lookup x map3

main :: IO ()
main = do
print (lookupMap1 1 >>= lookupMap2) -- Just 100
print (lookupMap1 1 >>= lookupMap2 >>= lookupMap3) -- Just "otherString1"
print (lookupMap1 3 >>= lookupMap2) -- Nothing
print (lookupMap1 2 >>= lookupMap2 >>= lookupMap3) -- Nothing

Так же в классе Monad присутствует операция >>, которая принимает 2 аргумента в контексте и отбрасывает первый:
ghci> Just 3 >> Just 4
Just 4

Это полезно в том случае, если результат первого действия нам не нужен, например при работе с IO:
ghci> putStrLn "put some line to echo:" >> getLine >>= putStrLn


Законы

- Согласованность return и (>>=) или левая единица:
(return x >>= f) = f x

если мы берём значение, помещаем его в контекст по умолчанию с помощью функции return, а затем передаём его функции, используя операцию >>=, это равнозначно тому, как если бы мы просто взяли значение и применили к нему функцию.
- Ассоциативность (>>=):
((x >>= f) >>= g) = (x >>= (\y -> f y >>= g))

когда у нас есть цепочка применений монадических функций с помощью операции >>=, не должно иметь значения то, как они вложены.
- Правая единица:
(m >>= return) = m

если у нас есть монадическое значение и мы используем операцию >>= для передачи его функции return, результатом будет наше изначальное монадическое значение.
👍2
Монады и do-нотация

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

Например:
echoPrefix :: String -> String
echoPrefix s = "echo:" ++ s

monadEcho :: IO()
monadEcho = putStrLn "put some line to echo:"
>> getLine
>>= (\line -> pure (echoPrefix line))
>>= putStrLn

doEcho :: IO()
doEcho = do
putStrLn "put some line to echo:"
line <- getLine
putStrLn (echoPrefix line)


Функции monadEcho и doEcho эквивалентны, просто записанны в разных стилях, однако doEcho проще для восприятия и не перегружена операторыми.
На самом деле компилятор в процессе дешугаринга преврящает do-блок в цепочку >>= и doEcho превращается практически в monadEcho, за исключением использования >>:
desugarEcho :: IO()
desugarEcho = putStrLn "put some line to echo:"
>>= \_ -> getLine
>>= \line -> pure (echoPrefix line)
>>= putStrLn
👍1
Класс типов Alternative и функция guard

Класс Alternative расширяет класс аппликативов добавляя в него 2 функции - пустое значение и выбор:
class Applicative f => Alternative f where
empty :: f a
(<|>) :: f a -> f a -> f a


Функция empty возвращает неудачное вычисления в зависимости от конкретного инстанса: Nothing для Maybe, пустой список ([]) для списка. Потому IO не является экземпляром Alternative - у него не может быть пустого значения.
Оператор выбора <|> - выбор варианта из левого и правого значений. Для списков, как недетерменированных результатов, <|> реализован через объединение (++):
ghci> :m +Control.Applicative
ghci> Just 1 <|> Just 2
Just 1
ghci> Nothing <|> Just 2
Just 2
ghci> [1,2,3] <|> [4,5]
[1,2,3,4,5]
ghci> [] <|> [4,5]
[4,5]


Функция guard это фильтрация в монадических вычислениях. Она позволяет прервать цепочку вычислений, если определенное условие не выполнено.
guard :: Alternative f => Bool -> f ()

Если аргумент True, то возвращает пустое успешное значение (pure ()) и вычисления продолжаются. Если аргумент False, то возвращает пустое значение (empty).
ghci> guard (5 > 2) >> pure 1 :: Maybe Int
Just 1
ghci> guard (5 < 2) >> pure 1 :: Maybe Int
Nothing


Пример:
squares :: [Int]
squares = do
x <- [1..10]
guard (x `mod` 2 == 0)
return (x ^ 2) -- [4,16,36,64,100]


Код выше эквивалентен записанному через генератор списка:
squares :: [Int]
squares = [ x ^ 2 | x <- [1..10] , x `mod` 2 == 0]

в котором в условии фильтрации указана та же самая функция.
👍3
Стандартная монада Identity

Это минимальная, тождественная монада, которая не добавляет никакого дополнительного поведения или эффектов. То есть, эта монада, по сути, не меняет ни тип значений, ни стратегию связывания вычислений.
instance Monad Identity where
return x = Identity x
(Identity x) >>= f = f x


Сама она реализована как просто обёртка вокруг значения:
newtype Identity a = Identity { runIdentity :: a }
deriving (Eq, Ord, Show, Read, Functor, Applicative, Monad)


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

Например:
mport Data.Functor.Identity (runIdentity)

calculate :: Monad m => Int -> m Int
...

pureResult :: Int
pureResult = runIdentity (calculate 42)

если у нас есть функция calculate, которая работает с монадой m, но мы пока её не конкретизируем и используем для этого Identity.
👍2