Раздел 21 · Haskell

Исключения и ресурсы

senior~55 мин

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

Исключения и ресурсы

Часть ошибок мы давно кодируем типами: Maybe для отсутствия, Either для ожидаемого провала из урока 12. Но в IO есть и аварийные ситуации, которые в тип не лезут: файл не открылся, сеть отвалилась посреди запроса, соседний поток прислал сигнал отмены, кончилась память. Сегодня разбираем второй слой: как Haskell бросает и ловит исключения, почему здесь почти не было стектрейсов и что с этим стало, как гарантированно не утечь ресурсом и почему самое коварное исключение прилетает не оттуда, откуда ты его ждёшь, а из другого потока в произвольный момент.

Идея: две породы ошибок

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

Ожидаемое это часть нормальной работы функции. Пользователя нет в базе, строка не парсится в число, валидация не прошла. Такие исходы предсказуемы, их видно заранее, и мы кодируем их в типе результата: Maybe, Either DomainError, свой ADT из урока 06. Компилятор тогда заставляет вызывающего обработать неудачу, и она едет по тем же рельсам, что и успех. Это рабочая лошадка, и в чистом коде других ошибок быть не должно.

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

Правило границы простое и его стоит запомнить дословно. Ожидаемое кодируй типом, аварийное бросай исключением. Нарушают его в обе стороны, и обе плохи. Бросать исключение там, где провал ожидаем (пользователь не найден), значит прятать важный исход от системы типов и от вызывающего. Тащить Either через всё ради ситуации, которую всё равно никто не обрабатывает осмысленно (кончилась память), значит зашумлять код ради иллюзии контроля. Дальше весь урок про вторую породу, но граница важнее любого API: если поймал себя на Either для невозможного или на error для обычного, остановись.

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

Ошибки в чистом коде

Начнём с того, что чистая функция, у которой в типе нет ни Maybe, ни IO, всё равно может взорваться. Вот источники.

error "невозможная ситуация"   :: a   -- бросает ErrorCall
undefined                      :: a   -- заглушка, бросает при касании
head []                              -- Prelude.head: empty list
1 `div` 0                            -- divide by zero
fromJust Nothing                     -- Maybe.fromJust: Nothing
let xs = [1, 2] in xs !! 5           -- index too large

Все они возвращают значение типа a, любого типа, и при этом значения у них нет. Это и есть ⊥ (bottom), особый обитатель каждого типа: вместо настоящего значения там сидит вычисление, которое не доходит до конца, а бросает. Из-за этого ни один тип в Haskell не населён только честными значениями: в Int всегда есть ещё и ⊥. Поэтому тотальность (функция определена на всех входах и всегда возвращает значение) это обещание, которое даёшь ты, а не гарантия, которую проверяет компилятор.

Теперь главная ловушка, прямо из ленивости урока 08. Исключение в чистом коде прячется внутри thunk-а и всплывает не там, где записано, а там, где значение форсируют.

let x = head []        -- здесь ничего не падает: x это thunk
in  putStrLn "живы" >> print x
-- напечатает "живы", и только потом упадёт на print

Строка с head [] отрабатывает спокойно, потому что x просто откладывается. Взрыв случается позже, на print x, в совсем другом месте программы. Для строгого языка это немыслимо, а здесь норма, и именно поэтому пустой стектрейс в Haskell так долго был обычным делом: к моменту падения исходный контекст давно испарился. К этому вернёмся в следующем разделе.

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

error "left" + error "right"   -- какое из двух? спецификация не обещает

Компилятор волен считать аргументы + в любом порядке, и оба варианта законны. Практический вывод: на конкретный тип чистого исключения полагаться нельзя. Если исход важен, его надо втащить в IO, где порядок эффектов задан жёстко, и ловить там.

Отсюда и стиль. Частичные функции (head, tail, fromJust, !!, неполные сопоставления) это мины. Их не запрещают совсем, но держат на коротком поводке:

  • head/tail меняй на сопоставление с образцом или на Data.List.NonEmpty, где непустота закодирована типом;
  • fromJust меняй на maybe, fromMaybe, case;
  • включи -Wincomplete-patterns (а лучше -Wall), чтобы компилятор показывал неполные case;
  • проектируй типы так, чтобы недопустимое состояние было непредставимым, как мы делали в уроке 06: тогда частичности просто неоткуда взяться.

error оставляют для честно невозможного: ветки, в которую программа не может попасть, если инварианты соблюдены. Это не обработка ошибки, это утверждение “сюда не дойдём”, и падение здесь означает баг в наших же предпосылках.

Где же стек?

Теперь обещанный тяжёлый разговор. Программист, пришедший из Java или Python, первым делом спрашивает: где стектрейс? Он привык, что исключение тащит за собой цепочку вызовов, по которой видно, откуда пришли. В Haskell исторически ты получал ровно строку Prelude.head: empty list и ни байта контекста. Разберём, почему так, потому что причина прямо вытекает из всего, что мы уже знаем про язык, а потом, что с этим стало в современных GHC.

Причин у пустого стека три, и все фундаментальные.

Ленивость рвёт связь со стеком вызовов. В строгом языке стек в момент падения это и есть история “кто кого позвал”. В Haskell значение, которое сейчас форсируется и бросает, было собрано thunk-ом где-то совсем в другом месте и в другое время. Стек в момент взрыва показывает того, кто спросил значение, а не того, кто его построил, а виноват обычно второй. Мы это только что видели: head [] упал на print, и стек честно укажет на print, что бесполезно. Связь между местом ошибки и местом её причины ленивость разрывает физически.

У GHC нет стека вызовов в привычном виде. Рантайм исполняет не дерево вызовов функций, а граф thunk-ов, и стек у него свой: update-фреймы и продолжения из урока 32, а не именованные кадры вызовов. По нему нельзя восстановить “main позвал handler, тот позвал parse”: там лежит механика обновлений и форсирования, а не семантика вызовов.

Оптимизатор стирает границы. Даже та структура, что есть, после -O2 неузнаваема: инлайнинг, let-floating, fusion из урока 36 растворяют исходные функции. Кадра с именем parseConfig в оптимизированном бинаре может просто не существовать, его тело вплавлено в вызывающего.

Сложить это всё значит понять: пустой стектрейс в Haskell не лень разработчиков рантайма, а прямое следствие ленивости и модели исполнения. Бесплатно стек тут взять неоткуда. Поэтому контекст для ошибки приходится добывать отдельными механизмами, и их несколько, по нарастанию.

HasCallStack: явный стек, который несут типы. Самый дешёвый способ, встроенный в base. Это неявное ограничение, которое функция объявляет в сигнатуре, и тогда GHC протаскивает через неё точку вызова.

import GHC.Stack (HasCallStack, callStack, prettyCallStack)

myHead :: HasCallStack => [a] -> a
myHead (x : _) = x
myHead []      = error "myHead: пустой список"

-- сообщение error при HasCallStack уже включает стек тех вызовов,
-- что объявили HasCallStack по всей цепочке

Хитрость в том, что этот стек собирается на уровне типов во время компиляции, а не из рантайма. Поэтому он невосприимчив к ленивости и оптимизациям: GHC знает точку вызова статически. Цена: стек виден только там, где HasCallStack протянут через всю цепочку. Стоит одной промежуточной функции его не объявить, и цепочка рвётся. Многие частичные функции Prelude (head, fromJust) уже несут HasCallStack, поэтому их сообщения стали чуть полезнее. error и undefined тоже им пользуются.

Профильные стеки cost-центров. Если собрать с профилированием (-prof -fprof-auto), рантайм ведёт стек центров затрат, и флаг +RTS -xc печатает его при исключении. Это уже настоящий стек, полный, но платишь профильной сборкой (медленнее, другой бинарь), как мы видели в профиле кучи урока 32.

Современный механизм бэктрейсов (GHC 9.10+). Самое важное изменение. Теперь SomeException несёт с собой контекст с бэктрейсом, и его печатает displayException. Источников бэктрейса несколько, и их набор настраивается через setBacktraceMechanisms:

import Control.Exception.Backtrace (BacktraceMechanism (..), setBacktraceMechanisms)

-- какие источники собирать в бэктрейс брошенного исключения
setBacktraceMechanisms
  [ HasCallStackBacktrace   -- из HasCallStack
  , CostCentreBacktrace     -- из профильных cost-центров
  , IPEBacktrace            -- из карты info-таблиц
  , ExecutionStackBacktrace -- из DWARF-отладочной инфы
  ]

Особенно интересен IPE-бэктрейс: он строится из карты info-таблиц, которую включают флаги -finfo-table-map и -fdistinct-constructor-tables. Это те же самые флаги, что в уроке 32 давали профилю кучи конкретные строки исходника. Та же карта теперь привязывает кадры бэктрейса к точкам кода без профильной сборки: можно получить осмысленный контекст на почти боевом оптимизированном бинаре. Для прода это и есть тот самый сдвиг, после которого слова “в Haskell нет стектрейсов” наконец устарели.

Практический итог раздела. Стек не появляется сам, его надо включить: HasCallStack на своих частичных функциях даром, -finfo-table-map плюс современный механизм бэктрейсов для прода, +RTS -xc на профильной сборке для тяжёлых случаев. И всегда помни про ленивость: точка взрыва не равна точке причины, поэтому первый вопрос всегда “кто построил это значение”, а не “кто его спросил”.

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

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

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