Обработка ошибок: Result и railway-oriented programming

Ошибки как значения: тип Result делает успешный и ошибочный путь явными, без исключений.

Result — размеченное объединение с вариантами Ok значение (успех) и Error ошибка (неудача); ошибки становятся обычными значениями, которые видны в типе.

Зачем Result, а не исключения

Исключения невидимы в сигнатуре: глядя на функцию, не скажешь, что она может бросить. Result же честно объявляет в типе: «вернёт либо успех, либо ошибку». Это поощряет обработать оба исхода, как с option, но с информацией об ошибке.

let divide a b =
    if b = 0 then Error "деление на ноль"
    else Ok (a / b)

printfn "%A" (divide 10 2)
printfn "%A" (divide 10 0)

Вывод:

Ok 5
Error "деление на ноль"

Тип divideint -> int -> Result<int, string>: успех несёт int, ошибка — строку-сообщение.

Railway-oriented programming

Представьте вычисление как две параллельные рельсы: «успех» и «ошибка». Пока всё хорошо, поезд едет по рельсе успеха; первая ошибка переводит его на рельсу ошибки, и дальнейшие шаги пропускаются. Это и есть railway-oriented programming.

// схема двух рельсов
  Ok ──> шаг1 ──> шаг2 ──> шаг3 ──> Ok
           │        │        │
           v        v        v
 Error ───────────────────────> Error

Связующее звено — Result.bind: он применяет следующий шаг только к Ok, а Error протягивает дальше нетронутым.

Цепочка через bind

let parseAge (s: string) =
    match System.Int32.TryParse s with
    | true, n -> Ok n
    | _ -> Error "не число"

let validate n =
    if n >= 0 && n < 150 then Ok n
    else Error "возраст вне диапазона"

let process s =
    parseAge s
    |> Result.bind validate

printfn "%A" (process "42")
printfn "%A" (process "-5")
printfn "%A" (process "abc")

Вывод:

Ok 42
Error "возраст вне диапазона"
Error "не число"

Как только один шаг вернул Error, следующие не выполняются — поезд ушёл на рельсу ошибки.

map для успешной рельсы

Result.map преобразует значение внутри Ok, не трогая Error — когда следующий шаг не может сам провалиться.

let doubled = Ok 21 |> Result.map (fun x -> x * 2)
printfn "%A" doubled

Вывод:

Ok 42

Как работает под капотом

Result — это DU type Result<'T,'E> = Ok of 'T | Error of 'E. bind внутри — простой match: для Ok x он вызывает следующую функцию, для Error e возвращает ошибку как есть. Цепочка bind и есть «рельсы»: ошибка короткозамыкает остаток. Никаких исключений и раскрутки стека — только передача значений, что делает поток управления явным и предсказуемым.

Частые ошибки

  • Путать map (следующий шаг не может провалиться) и bind (шаг сам возвращает Result).
  • Смешивать исключения и Result бессистемно — выберите единый стиль для слоя.
  • Терять информацию об ошибке: тип ошибки ('E) тоже стоит проектировать осмысленно (DU, а не голая строка).

Итоги

  • Result делает ошибку значением: Ok — успех, Error — неудача с данными.
  • Сигнатура честно показывает возможность ошибки — её нельзя «забыть».
  • Railway-oriented programming: bind ведёт по рельсе успеха, первая ошибка уводит на рельсу ошибки.
  • map преобразует Ok; bind — когда шаг сам может вернуть Error.
Проверьте себя
1. Какие варианты у типа Result?
ASome и None
BOk значение и Error ошибка
Ctrue и false
DPass и Fail
2. Что делает Result.bind при Error?
AБросает исключение
BПропускает следующий шаг и протягивает Error дальше
CПревращает Error в Ok
DОстанавливает программу
3. Чем Result лучше исключений для ожидаемых ошибок?
AОн быстрее работает
BВозможность ошибки видна в типе и её нельзя забыть обработать
CОн не требует match
DОн всегда возвращает Ok