Раздел 21 · Haskell

Monad: зависимые шаги и do-нотация

middle-senior~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Monad: зависимые шаги и do-нотация

Вершина базовой иерархии. Аппликатив умел применять обёрнутую функцию, но каждый шаг был слеп к результату предыдущего. Монада снимает это: её >>= пробрасывает результат одного шага в построение следующего. В этом уроке: >>= и его смысл, do-нотация как чистый сахар над >>=, три закона, Maybe/Either/список/функция как монады, помощники when/unless/mapM_, и честный разговор о том, где монады заканчиваются.

Идея

Аппликатив упёрся в стену: в f <$> x <*> y шаг y не может зависеть от того, что вернул x. Структуры известны заранее, их только склеивают. А жизнь требует ветвления по результату: распарсил число, и в зависимости от него решаешь, что делать дальше.

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

lookup2 :: Eq k => k -> k -> [(k, v)] -> [(v, w)] -> Maybe w
lookup2 k1 k2 m1 m2 =
  case lookup k1 m1 of
    Nothing -> Nothing
    Just _  -> case lookup k2 m2 of
      Nothing -> Nothing
      Just w  -> Just w

Лесенка из case, и она растёт с каждым шагом. Заметь паттерн: на Nothing бросаем всё, на Just x достаём x и идём дальше. Этот паттерн (продолжай только при успехе, пробрасывай неудачу) и есть Monad:

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

Applicative m => снова держит иерархию: монада это аппликатив, умеющий ещё одно. Главный метод это >>= (читается bind). Смотри на тип внимательно: слева структура m a, справа функция a -> m b, которая получает распакованное значение a и строит следующую структуру. Вот оно, отличие от <*>: функция справа видит результат и решает, что вернуть. return это синоним pure, исторический.

Инстанс Maybe это ровно наша лесенка, свёрнутая в одно правило:

instance Monad Maybe where
  Nothing >>= _ = Nothing
  Just x  >>= f = f x

И lookup2 сжимается:

lookup2 k1 k2 m1 m2 =
  lookup k1 m1 >>= \_ ->
  lookup k2 m2 >>= \w ->
  Just w
ghci> lookup 'a' [('a', 1), ('b', 2)] >>= \v -> lookup v [(1, "один"), (2, "два")]
Just "один"
ghci> lookup 'z' [('a', 1)] >>= \v -> lookup v [(1, "один")]
Nothing

Nothing в любом звене обрывает цепочку, и тебе не нужно проверять это руками: за тебя это делает инстанс. bind пробрасывает результат и эффект.

do-нотация это сахар

Цепочки >>= \x -> читаются тяжело, поэтому у них есть синтаксический сахар, do-нотация. Те же три строки:

lookup2 k1 k2 m1 m2 = do
  _ <- lookup k1 m1
  w <- lookup k2 m2
  pure w

И это сахар, компилятор разворачивает do обратно в >>= по двум механическим правилам:

-- do { x <- e; rest }   разворачивается в   e >>= \x -> do { rest }
-- do { e; rest }        разворачивается в   e >>  do { rest }
-- do { e }              это просто           e

x <- e это не присваивание, а распаковка через >>=: возьми значение из структуры e и назови его x. Стрелка <- живёт только внутри do. А >> это >>=, игнорирующий результат (когда x не нужен). Важно: do-нотация работает для любой монады, не только для IO. Та же запись соберёт и Maybe, и Either, и список.

Три закона

-- 1. левое тождество:   return a >>= f   ==  f a
-- 2. правое тождество:  m >>= return     ==  m
-- 3. ассоциативность:   (m >>= f) >>= g  ==  m >>= (\x -> f x >>= g)

Первые два говорят, что return (он же pure) ничего не добавляет: обернуть и сразу развернуть это нет-оп. Третий это ассоциативность из урока про моноиды, но для bind: группировка шагов не меняет результат, поэтому do-блок можно дробить на под-блоки. Законы держит любой честный инстанс; нарушишь, и do-нотация начнёт врать.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.