Нужна ли тебе эффект-система
Ты получил разрешение переписать старый сервис. Он принимает сообщения, обращается к базе, зовёт соседей и пишет логи. Никакого собственного языка запросов, перебора продолжений или необычного планировщика. Но вокруг этих действий выросла эффект-система: объявления операций, обработчики, ограничения в сигнатурах и сборка всего стека на входе.
Что из этого сервису нужно, а что появилось потому, что так было принято писать Haskell?
В статье You don’t need an effect system от 3 октября 2026 года BurningWitness рассказывает о такой переписи. Его ответ: нужные ему свойства удалось получить в обычном IO, передавая зависимости аргументами. Этот урок адаптирует аргументы и примеры статьи. Проверки границ Early, оговорки о безопасности и упражнения добавлены для курса; это не дословный перевод и не универсальный запрет эффект-систем.
Главная мысль: выбирай механизм под нужное свойство, а не свойство под уже выбранную библиотеку. Карта эффект-систем показала, что библиотеки покупают. Здесь посмотрим, что можно купить без них.
Сначала инвентаризация
Автор задаёт себе вопросы, на которые стоит ответить до выбора стека.
| Вопрос | Ответ автора для его сервиса | Что проверить у себя |
|---|---|---|
| Нужна передача зависимостей? | Аргументы функций её закрывают | Сколько зависимостей у одной операции? |
| Нужна безопасность? | Список эффектов не запрещает плохую реализацию | Какую ошибку должен исключить тип? |
| Нужна мутация внутри чистого вычисления? | Есть ST | Должно ли состояние покидать вычисление? |
| Нужно отдельно отслеживать каждый небольшой эффект? | Не всегда, например генерацию случайных значений | Нужен ли фиксированный генератор для воспроизводимости? |
| Нужны эффекты высшего порядка? | Его операции не требуют такой модели | Нужны ли собственные local, catch, управление вложенными программами? |
| Что осталось после этих ответов? | Обработка ошибок и досрочный выход | Нужен ли ради них отдельный носитель вычислений? |
Не превращай ответы автора в ответы за свою команду. Явный генератор случайных чисел полезен для повторяемых тестов. Скрытая зависимость от глобального генератора может этому мешать. Но из такой потребности ещё не следует, что нужен весь эффектный стек: генератор тоже можно передать аргументом.
Отдельно про безопасность. Eff es a ограничивает доступ к операциям в коде, который соблюдает интерфейс библиотеки. Это полезно. Но реализация разрешённого эффекта всё равно может удалить не тот файл, уйти в бесконечное вычисление или воспользоваться небезопасным API. Учёт эффектов не равен доказательству корректности их реализаций.
Обратный вывод тоже неверен: существование unsafePerformIO не делает ограничения бесполезными. Небезопасный обход есть и у других границ типов; вопрос в том, какие ошибки предотвращает обычный код без обходов.
Досрочный выход не требует новой монады
Представь обработчик: прочитал данные, проверил условие, при отказе не выполняй остальные действия.
У Haskell return не завершает функцию. Он заворачивает значение в монаду, как pure. Из do можно выйти через структуру ветвлений, ExceptT, продолжения или исключение. Эффект-библиотека тоже должна выбрать механизм исполнения ошибки; название операции не создаёт отдельный способ выхода.
Автор предпочитает исключения: обычный успешный путь идёт в IO, а отказ прыгает к обработчику. Он противопоставляет это ExceptT e IO, где результат каждого шага содержит Either.
Здесь нужна поправка. ExceptT из урока про трансформеры уже умеет останавливать do на Left, поэтому вручную сдвигать вправо каждую следующую строку не нужно. А утверждение «передача Either очень неэффективна» без измерений не годится как правило: итог зависит от оптимизаций GHC и самой нагрузки. По статье можно выбрать кандидата для сравнения, но нельзя получить результат измерения своего сервиса.
Модуль Early из статьи
Автор вводит значение, которое разрешает вызвать leave. Как ключ от двери: не надо протаскивать через каждую строку план эвакуации, передай ключ тому, кто вправе выйти.
Сохрани следующий блок в Early.hs. Экспортируем тип и две операции, но не конструкторы:
{-# LANGUAGE RoleAnnotations #-}
module Early (Early, leave, runEarly) where
import Control.Exception (Exception(..), catch, throwIO)
import Data.Typeable (Typeable)
type role Early nominal
data Early a = Early
data ReturningEarly a = ReturningEarly a
instance Typeable e => Show (ReturningEarly e) where
show = displayException
instance Typeable e => Exception (ReturningEarly e) where
displayException (ReturningEarly _) = "Early return exception"
leave :: Typeable e => Early e -> e -> IO a
leave _early e = throwIO (ReturningEarly e)
runEarly :: Typeable e => (Early e -> IO a) -> IO (Either e a)
runEarly f =
catch (Right <$> f Early) (\(ReturningEarly e) -> pure (Left e))
Номинальная роль нужна потому, что e не хранится внутри Early e: без явной роли этот параметр фантомный. Не хотим превращать ключ для одной ошибки в ключ для другой через coerce.
ReturningEarly скрыт. Вызывающий код получает разрешение Early e, но не может напрямую собрать внутреннее исключение через экспорт модуля.
Теперь Main.hs, запуск через runghc Main.hs:
module Main where
import Early
example :: Early () -> IO Bool
example early = do
putStrLn "До выхода"
leave early ()
putStrLn "После выхода"
pure True
main :: IO ()
main = runEarly example >>= print
До выхода
Left ()
Механика по шагам:
runEarlyустанавливает обработчикReturningEarly eи передаёт ключ в тело.leaveбросает исключение с полезной нагрузкойe.- Остальные действия тела пропускаются. Вложенные
bracketвыполняют освобождение ресурсов при выходе через исключение. - Обработчик превращает исключение в
Left e. Нормальное завершение даётRight a.
Это локальная реализация управления потоком через механизм исключений. Ей не нужен polysemy или effectful.
Где кончается гарантия типов
В статье пример назван типобезопасными исключениями, а ограничения сравниваются с bracket. Не прочитай это как «компилятор доказал, что ключ живёт только внутри обработчика». Приведённый тип этого не доказывает.
Ключ можно вынести наружу. Следующий код компилируется, хотя конструктор скрыт:
escape :: IO (Either String (Early String))
escape = runEarly (\early -> pure early)
В ветке Right early ключ существует, а его runEarly уже закончился. Вызов leave early "поздно" бросит исключение за пределами прежнего обработчика. Значение также можно сохранить в IORef или передать другому потоку. Сам bracket тоже не запрещает вернуть файловый дескриптор, который уже закрыт.
Вложенные обработчики с одинаковым e не различают ключи. Добавь в Main.hs:
nested :: IO (Either String String)
nested = runEarly $ \outer -> do
inner <- runEarly $ \_inner -> leave outer "внешний выход"
print (inner :: Either String ())
pure "внешнее тело продолжилось"
ghci> nested
Left "внешний выход"
Right "внешнее тело продолжилось"
Ты передал outer, но исключение поймал внутренний runEarly: оба ловят один тип ReturningEarly String. Значение ключа не содержит уникального идентификатора обработчика, а leave его даже не читает. nominal запрещает определённое преобразование типов, но не создаёт идентичность двух значений одного типа.
Общий catch может перехватить выход. Если между leave и runEarly стоит обработчик SomeException, он увидит и это внутреннее исключение. Если проглотит его, тело может продолжиться. В другом потоке обработчика из текущего потока тоже нет.
Для учебного однопоточного примера с одним обработчиком код показывает идею. Для библиотечного API сначала определи требования к вложенности, времени жизни и потокам. Генеративная метка области или идентификатор обработчика требуют отдельного дизайна; скрытого конструктора недостаточно. Этот урок не выдаёт пример статьи за готовый универсальный API.
И не меняй общее правило урока об исключениях: ожидаемые ошибки должны быть видны вызывающему коду. На границе runEarly они видны как Either e a; внутри обычная сигнатура IO a не перечисляет все возможные отказы.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.