Раздел 21 · Haskell

Ошибки как значения: EvalError и ExceptT

senior~35 мин

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

Ошибки как значения: EvalError и ExceptT

Пока eval при любой проблеме просто падает. Сделаем ошибки честной частью языка: тип EvalError со случаями (неизвестный символ, неверная арность, не-вызываемое в голове, деление на ноль, пользовательский raise), монада вычислителя ExceptT EvalError IO, throwError для прерывания, читаемые сообщения. Сначала отвлечённые примеры на Either и ExceptT без проекта, потом интеграция в eval.

Идея

Прошлый eval при первой беде падал через error: деление на ноль рушило всю программу со стектрейсом. Это плохо по двум причинам. Во-первых, REPL не должен умирать от опечатки пользователя. Во-вторых, и это глубже, ошибка тут ведёт себя как скрытый второй выход из функции. Сигнатура обещает LishpVal, а на деле может прилететь крах.

Сменим взгляд: ошибка это не “программа сломалась”, а обычное значение, которое можно вернуть, передать дальше, обработать. Ровно та же идея, что в Either и в сериях про Result и Effect: провал делается видимым в типе, его нельзя случайно забыть. Вычислитель будет жить в типе, который говорит “верну LishpVal или ошибку EvalError”, и эта развилка станет частью каждого шага.

Отвлечённо: Either и ExceptT

Сначала разберёмся вне проекта. Вот безопасное деление, которое вместо краха возвращает значение-ошибку:

data MyError = DivZero | Negative
  deriving (Show)

safeDiv :: Int -> Int -> Either MyError Int
safeDiv _ 0 = Left DivZero
safeDiv a b = Right (a `div` b)

Either это монада, и её эффект это короткое замыкание: цепочка do идёт по Right, а первый же Left обрывает всё и становится результатом. Никаких проверок руками:

calc :: Either MyError Int
calc = do
  x <- safeDiv 100 5     -- Right 20
  y <- safeDiv x 0       -- Left DivZero, дальше не пойдём
  pure (x + y)           -- сюда не дойдёт
-- calc == Left DivZero

Но нашему вычислителю мало чистого Either: ему нужно ещё печатать (print), читать (read), грузить файлы, то есть IO. Совместить “может упасть” и “делает IO” умеет ExceptT, трансформер из Control.Monad.Except. ExceptT e IO a это “вычисление в IO, дающее либо ошибку e, либо a”:

import Control.Monad.Except

safeDivLog :: Int -> Int -> ExceptT MyError IO Int
safeDivLog a b = do
  liftIO (putStrLn ("делю " ++ show a ++ " на " ++ show b))  -- IO внутри
  if b == 0 then throwError DivZero                          -- прерваться значением
            else pure (a `div` b)

-- запуск разворачивает ExceptT обратно в IO (Either MyError Int)
run :: IO (Either MyError Int)
run = runExceptT (safeDivLog 10 2)

Три новых слова, которые дальше всюду. liftIO поднимает IO-действие внутрь ExceptT (печать лога). throwError прерывает вычисление значением-ошибкой (вместо error). runExceptT запускает всё и отдаёт IO (Either e a). Есть ещё catchError, чтобы поймать ошибку и продолжить, он нам понадобится в макросах.

Короткое замыкание проще увидеть, чем описать. Прогони цепочку из четырёх шагов в ExceptT EvalError IO сам: в режиме “успех” значение течёт по всем bind до конца, а переключи на “ошибку на делении”, и третий шаг бросит throwError, станет Left и проскочит мимо оставшегося шага прямо в результат.

Тип EvalError

Теперь перечислим все способы, которыми вычисление lishp может пойти не так. Это закрытый список, обычный sum-type:

data EvalError
  = UndefinedVariable String                   -- символ не найден в окружении
  | WrongArgumentCount String Int Int          -- форма, ожидалось, получили
  | MissingElements Int Int                     -- в списке мало элементов
  | ExtraElements String                        -- в списке лишние элементы
  | WrongHeadForm String                        -- голову нельзя вызвать
  | DivisionByZero
  | ExpectedList String
  | CarOfNil | CdrOfNil
  | TypeError String
  | ParseFailure String
  | InvalidBinaryOp String String String        -- оператор и два типа
  | InvalidBinaryPred String String String
  | RuntimeError String                          -- пользовательский raise
  -- ... и ещё несколько частных случаев

Каждый конструктор несёт ровно то, что нужно, чтобы внятно объясниться. И Show, который превращает ошибку в человеческую строку для REPL:

instance Show EvalError where
  show = \case
    UndefinedVariable name -> "Undefined variable: " ++ name
    WrongArgumentCount form expected got ->
      "Wrong number of arguments for " ++ form
        ++ ": expected " ++ show expected ++ ", got " ++ show got
    DivisionByZero -> "Division by zero"
    WrongHeadForm v -> "Cannot call " ++ v ++ " as a function"
    InvalidBinaryOp op lt rt ->
      "Invalid binary operator " ++ op ++ ": cannot apply to types ("
        ++ lt ++ ", " ++ rt ++ ")"
    RuntimeError s -> s          -- raise отдаёт текст как есть
    -- ... остальные ветки по образцу

Заметь RuntimeError: это мост наружу, в сам язык. Спец-форма raise (она будет в уроке про спец-формы) берёт значение из кода на lishp и заворачивает его в RuntimeError. То есть снаружи язык выглядит как язык с исключениями, а внутри это аккуратное значение Left.

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

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

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