Ошибки как значения: 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.