Haskell часто описывают как язык для математиков, но на практике его главная ценность — предсказуемость. Чистые функции не зависят от глобального состояния, типы фиксируют контракты ещё до запуска, а побочные эффекты собраны в IO. Это снижает число неожиданных багов в долгоживущих сервисах. Ниже — рабочая траектория: инструменты, моделирование типов, обработка ошибок, сборка и профилирование без магии.
Где функциональный подход окупается
Haskell выбирают для компиляторов, парсеров, финансовых расчётов, обработки потоков данных и backend-сервисов с жёсткими требованиями к корректности. Он не даёт «серебряную пулю»: старт медленнее, экосистема меньше, а найм дороже. Зато рефакторинг большого кода проходит спокойнее, потому что компилятор проверяет больше, чем тесты в динамическом языке.
- Явные типы для домена: деньги, идентификаторы, единицы измерения.
- Ошибки как значения Either вместо исключений.
- Чистое ядро и тонкая IO-обвязка.
Инструменты: ghcup, cabal и редактор
Не смешивайте пакеты из дистрибутива с инструментами ghcup: версии GHC и библиотек начнут конфликтовать. ghcup ставит компилятор и cabal в домашний каталог, после чего проект собирается одинаково на любой машине.
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
ghcup install ghc
ghcup install cabal
cabal update
В редакторе подключите Haskell Language Server, hlint и форматтер fourmolu. Это не роскошь: HLS показывает типы прямо в коде и ловит ошибки до сборки, а форматтер убирает споры о пробелах на ревью.
Типы как каркас программы
Начните с доменных типов, а не с Generic и сложных абстракций. newtype отличает email от обычной строки, а тип-сумма делает невозможные состояния непредставимыми.
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DerivingStrategies #-}
newtype Email = Email Text
deriving stock (Eq, Show, Ord)
data Role = Admin | Editor | Viewer
deriving stock (Eq, Show)
data User = User
{ userName :: Text
, userEmail :: Email
, userRole :: Role
} deriving stock (Eq, Show)
Смарт-конструктор переносит проверку в одно место и возвращает Either. Такой подход удобнее разрозненных if: вызывающий код обязан обработать ошибку, и компилятор не даст её проигнорировать.
Ошибки без исключений
mkEmail :: Text -> Either Text Email
mkEmail raw
| "@" `Text.isInfixOf` raw && Text.length raw <= 254 =
Right (Email (Text.toLower raw))
| otherwise = Left "Некорректный адрес"
parseRole :: Text -> Either Text Role
parseRole "admin" = Right Admin
parseRole "editor" = Right Editor
parseRole "viewer" = Right Viewer
parseRole other = Left ("Неизвестная роль: " <> other)
Either удобен для последовательных проверок, но не для сбора всех ошибок сразу. Если нужно показать пользователю полный список, используйте Validation из библиотеки или аккуратно комбинируйте результаты через traverse.
Чистое ядро и IO на границе
Держите бизнес-логику в чистых функциях, а чтение файлов, сеть и время выносите в main. Тогда ядро тестируется без моков и запускается в ghci.
parseConfig :: Text -> Either Text [User]
parseConfig input =
traverse parseLine (filter (not . Text.null) (Text.lines input))
parseLine :: Text -> Either Text User
parseLine line = case Text.splitOn ";" line of
[name, mail, role] -> User name <$> mkEmail mail <*> parseRole role
_ -> Left ("Ожидалось три поля: " <> line)
Applicative-стиль в parseLine читается как «собери пользователя из трёх проверок». Это стандартный способ комбинировать вычисления с ошибкой.
Ленивость, строгость и утечки памяти
Haskell ленив по умолчанию: выражение вычисляется только при необходимости. Это позволяет работать с бесконечными списками, но иногда приводит к накоплению отложенных вычислений и росту памяти. Для агрегатов используйте foldl’ из Data.List, строгие поля и bang patterns.
{-# LANGUAGE BangPatterns #-}
import Data.List (foldl')
import qualified Data.Map.Strict as Map
countWords :: [Text] -> Map.Map Text Int
countWords = foldl' (\acc w -> Map.insertWith (+) w 1 acc) Map.empty
summarize :: [Int] -> (Int, Int)
summarize = go 0 0
where
go !n !s [] = (n, s)
go !n !s (x:xs) = go (n + 1) (s + x) xs
Data.Map.Strict вычисляет значения до WHNF при вставке, но не спасает от накопления thunk-ов внутри сложных значений. Для действительно горячих структур добавляйте строгие поля или включайте StrictData осознанно, проверив, что это не меняет семантику.
Сборка, тесты и профилирование
cabal init --interactive
cabal build all
cabal test --test-show-details=direct
cabal repl
В cabal-файле включите -Wall -Wcompat и превращайте предупреждения в ошибки только после того, как разберёте существующие. Прогоняйте сборку в CI на той же версии GHC, что и локально, иначе различия проявятся в самый неподходящий момент. Для поиска утечек соберите с профилированием и запустите с +RTS -s: статистика покажет время сборки мусора и пиковую память. Детальный профиль по функциям даёт +RTS -p.
Для property-тестов подойдёт QuickCheck: сформулируйте закон, например «декодирование после кодирования возвращает исходное значение», и пусть генератор ищет контрпримеры. Юнит-тесты на tasty или HUnit дополняют картину, но не заменяют свойства.
Конкурентность без гонок
Легковесные потоки GHC дешёвые, а Control.Concurrent.Async упрощает ожидание группы задач. Для общего состояния используйте STM: транзакции композируются и не требуют ручных блокировок.
import Control.Concurrent.Async
import Control.Concurrent.STM
main :: IO ()
main = do
a <- async (fetch "https://example.com/a")
b <- async (fetch "https://example.com/b")
(ra, rb) <- waitBoth a b
print (ra, rb)
enqueue :: TQueue Text -> Text -> IO ()
enqueue q item = atomically (writeTQueue q item)
STM не делает IO атомарным: сетевой вызов внутри atomically недопустим. Сначала получите данные, затем обновите состояние одной транзакцией. Такой же принцип помогает в тестах: чистая логика проверяется быстро, а взаимодействие с внешним миром остаётся тонким.
Чек-лист внедрения
- Версии GHC и cabal зафиксированы, сборка воспроизводима.
- Доменные типы важнее примитивов, проверки живут в смарт-конструкторах.
- Чистое ядро отделено от IO.
- Включены предупреждения и форматтер в CI.
- Есть тесты свойств и профиль памяти на нагрузке.
Итог
Haskell требует дисциплины на старте, но платит за неё ясной архитектурой. Начните с ghcup и cabal, опишите предметную область типами, уберите побочные эффекты на границу и измеряйте память до оптимизаций. Функциональный подход — это не набор экзотических операторов, а привычка делать неверные состояния невозможными.