Skip to content

Latest commit

 

History

History
379 lines (292 loc) · 12.1 KB

doc.adoc

File metadata and controls

379 lines (292 loc) · 12.1 KB

Работа с ошибками

Ошибки - это события, за отсутствие которых программист готов заплатить.

Рассмотрим общие подходы работы с ошибками и реализацию этих подходов на примере Прекрасных Языков Будущего (Kotlin, Rust, Haskell).

Передача ошибок

Первое, что происходит с любой ошибкой после обнаружения - передача её выше по стеку.

Алгебраические типы данных

  • Типы-произведения.

  • Типы-суммы.

Типы произведения

C++
struct S {
    int i;
    double d;
};
Haskell
data D = D Int Double
-- or
data D = D
  { i :: Int
  , d :: Double
  }

Типы суммы

C++

Нет прямой поддержки в языке.

struct Result<T, E> {
    enum Type {
        OK,
        ERR
    } type;

    union U {
        T ok;
        E err;
    } data;
};

const auto res = get_result(...);
if (res.type == OK) {
    do_smth_next(res.data.ok)
}
Rust
enum Result<T, E> {
    Ok(T),
    Err(E),
}

let res = get_result(...);
match res {
    Ok(value) => do_smth_next(value),
    Err(e) => ...
}
Haskell
data Either a b = Left a | Right b

let res = get_result(...)
 in case res of
      Right value -> do_smth_next value
      Left e -> ...
Kotlin
sealed class Result<T, E>
data class Ok<T, E>(val value: T) : Result<T, E>
data class Err<T, E>(val error: E) : Result<T, E>

val res = getResult(...)
when(res) {
    is Ok -> doSmthNext(res.value),
    is Err -> ...,
}

Исключения vs типы-суммы

  • Исключения.

    • Хорошего:

      • Нативно поддерживаются языками, автоматический unwinding.

    • Плохого:

      • Информация о возможных исключениях содержится в документации.

      • Наличие обработки не гарантируется компилятором.

      • Нет пометок на call-site, что функция может кинуть исключение.

  • Алгебраические типы данных (типы-суммы).

    • Хорошего:

      • Информация о возможных ошибках содержится в типе результата функции.

      • Наличие какой-то обработки гарантируется компилятором.

      • На call-site видно, что функция может отработать неудачно.

    • Плохого:

      • Требуется вручную поднимать вверх по стеку до isolation boundary.

  • Middle-ground (Swift, Midory).

  • C-style: enum my_error err_code = do_smth(arg, *result);

    • Всё плохо.

Классификация ошибок

  • Не ошибки.

  • Ошибки программиста - нарушение контрактов и/или инвариантов, установленных в коде.

  • IO - события внешнего мира, мешающие решать бизнес-задачу. По факту тоже не ошибки (мы обязаны всего ожидать от враждебного мира), но коварны как настоящие ошибки!

Не ошибки

Результаты работы частичной чистой функции на разумных данных не из области определения.

Python

Not expecting None
d[key]
xs.last()
Expecting None
d.get(key)
xs.lastOrNone()

Haskell

head :: [a] -> a

-- vs

data NonEmpty a = a :| [a]

head :: NonEmpty a -> a
data Maybe a = Nothing | Just a

headMay :: [a] -> Maybe a

Хорошая практика в ошибку помещать как можно больше информации. Например, аргументы функции, которые привели к этой ошибке.

Чтобы ограничить зону ответственности за выполнение контракта, можно в коде, который его обеспечивает, формировать "доказательство" его выполнения. А в коде, который требует выполнения контракта, принимать "доказательство".

Итог по не ошибкам

  • Не используем исключения в статически типизированных языках.

  • В питоне:

    • None, если он ожидаем.

    • Исключение, если None ждать скорее всего не будут (неудачное имя функции). Лучше получить понятное исключение, чем 'NoneType' object is not something, you know.

Далее не ошибки не рассматриваем.

Ошибки - это сложно

  • Логику обработки ошибок сложнее писать, основная бизнес-логика обычно более линейная.

  • И сложнее тестировать (поэтому этим обычно не занимаются).

Almost all catastrophic failures (92%) are the result of incorrect handling of non-fatal errors explicitly signaled in software.

Поэтому не надо их обрабатывать

Стараемся обеспечить один code path для штатного завершения и для ошибок.

Закрываем ресурсы в любом случае

Anders Hejlsberg

It is funny how people think that important thing about exceptions is handling them. That is not the important thing about exceptions. In a well-written application there’s a ratio of ten to one, in my opinion, of try finally to try catch.

init_resources()
try:
    work_with_resources()
finally:
    cleanup_resources()

Провожаем ошибки до isolation boundary

Сыпем солому в одном месте, но сразу копну. Ровняем горку так, чтобы с неё можно было упасть только в копну.

Граница - это место на стеке вызовов, где просто обеспечить:
  • Сохранение консистентного состояния программы.

  • Разумную реакцию на ошибку.

  • Exception safety (транзакционность/базовую гарантию).

Границы бывают
  • Процесс.

  • Поток.

  • Запрос пользователя.

А ещё они бывают
  • Мягкими - граница библиотеки. Обрабатывать ошибки не умеем, но нужно завернуть в свои типы для сокрытия реализации.

  • Жесткими - ошибки ловим и обрабатываем. Если ловим конкретную ошибку на обработку, то возможно, что её можно обработать сразу после возникновения, на ближайшей границе.

Выбираем границу на стеке:
  • Ниже:

    • Ближе к контексту возникновения ошибки, лучше понимаем, что произошло.

    • Плохо понимаем, что делать с ней.

    • Любая попытка обработки - отклонение от стандартного плана исполнения, протестировать вряд ли удасться.

  • Выше:

    • Проще вернуть программу в консистентное состояние.

    • Программа проходит больший пусть в рамках стандартного плана исполнения.

    • Теряем часть информации об ошибке и обстоятельствах её возникновения.

Общие советы по необработке

  • Crash only software.

  • Восстановление из backup по ночам.

  • Программирование с паникой.

А как в Rust?

  • Для ошибок программиста - паника.

  • Для всего остального - возвращаемые значения

// Untipattern! https://matklad.github.io/2020/10/15/study-of-std-io-error.html
pub enum Error {
    WriteIO(io::Error)
}

impl From<io::Error> for Error {
    fn from(e: io::Error) -> Error {
        Error::WriteIO(e)
    }
}

impl From<png::EncodingError> for Error {
    fn from(e: png::EncodingError) -> Error {
        match e {
            png::EncodingError::IoError(e) => Error::WriteIO(e),
            png::EncodingError::Format(e) =>
                panic!("Unable to encode image as png \
                        (inconsistent image state): {}", e)
        }
    }
}

#[derive(Clone, Debug)]
pub struct Image(Vec<Vec<Color>>);

impl Image {
    pub fn from_size(w: NonZeroUsize, h: NonZeroUsize) -> Self {
        Image(vec![
            vec![Color::black(); usize::from(w)];
            usize::from(h)
        ])
    }

    pub fn write_png(&self, path: &Path) -> Result<(), Error> {
        let file = fs::File::create(path)?;
        // let file = match fs::File::create(path) {
        //     Ok(v) => v,
        //     Err(e) => return Error::from(e),
        // }
        let writer = io::BufWriter::new(file);
        let mut encoder = png::Encoder::new(writer, self.w() as u32, self.h() as u32);
        encoder.set_color(png::ColorType::RGB);
        encoder.set_depth(png::BitDepth::Eight);
        let data = self.linearized();
        Ok(encoder
            .write_header()?
            .write_image_data(&data)?)
    }

    ...
}

А как в Kotlin?

  • Для не ошибок - типы-суммы.

  • Для остального - исключения. IO ошибки, как и ошибки программиста, обычно поднимают высоко по стеку для централизованной обработки.

    • Для ошибок программиста используют стандартные исключения.

    • Для остальных - исключения из собственных иерархий.

Никаких checked exceptions!

Сладкое Nullability
val number: Int = string.toInt()
return number + 5
// vs
val number: Int? = string.toIntOrNull()
return number?.plus(5) ?: 42

А как в Haskell?

  • Для ошибок программиста _|_.

  • Для остальных - монады Maybe, Either, Except.

head :: [a] -> a
head []    = error "Ups..."
head (x:_) = x
kleisli :: Monad m => a -> m b

class Applicative m => Monad m where
  (>>=) :: m a -> (a -> m b) -> m b

instance Monad Maybe where
  (>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
  Nothing >>= _ = Nothing
  Just x  >>= k = k x

head2May :: [a] -> [b] -> Maybe (a, b)
head2May xs ys =
  headMay xs >>= \x ->
  headMay ys >>= \y ->
  Just (x, y)

-- Via do-notation
head2May xs ys = do
  x <- headMay xs
  y <- headMay ys
  Just (x, y)

-- Do-notation considered harmful...
head2May xs ys = (,) <$> headMay xs <*> headMay ys