Ошибки - это события, за отсутствие которых программист готов заплатить.
Рассмотрим общие подходы работы с ошибками и реализацию этих подходов на примере Прекрасных Языков Будущего (Kotlin, Rust, Haskell).
Первое, что происходит с любой ошибкой после обнаружения - передача её выше по стеку.
-
Типы-произведения.
-
Типы-суммы.
Нет прямой поддержки в языке.
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)
}
enum Result<T, E> {
Ok(T),
Err(E),
}
let res = get_result(...);
match res {
Ok(value) => do_smth_next(value),
Err(e) => ...
}
data Either a b = Left a | Right b
let res = get_result(...)
in case res of
Right value -> do_smth_next value
Left e -> ...
-
Исключения.
-
Хорошего:
-
Нативно поддерживаются языками, автоматический unwinding.
-
-
Плохого:
-
Информация о возможных исключениях содержится в документации.
-
Наличие обработки не гарантируется компилятором.
-
Нет пометок на call-site, что функция может кинуть исключение.
-
-
-
Алгебраические типы данных (типы-суммы).
-
Хорошего:
-
Информация о возможных ошибках содержится в типе результата функции.
-
Наличие какой-то обработки гарантируется компилятором.
-
На call-site видно, что функция может отработать неудачно.
-
-
Плохого:
-
Требуется вручную поднимать вверх по стеку до isolation boundary.
-
-
-
Middle-ground (Swift, Midory).
-
C-style:
enum my_error err_code = do_smth(arg, *result);
-
Всё плохо.
-
-
Не ошибки.
-
Ошибки программиста - нарушение контрактов и/или инвариантов, установленных в коде.
-
IO - события внешнего мира, мешающие решать бизнес-задачу. По факту тоже не ошибки (мы обязаны всего ожидать от враждебного мира), но коварны как настоящие ошибки!
Результаты работы частичной чистой функции на разумных данных не из области определения.
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 для штатного завершения и для ошибок.
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()
Сыпем солому в одном месте, но сразу копну. Ровняем горку так, чтобы с неё можно было упасть только в копну.
-
Сохранение консистентного состояния программы.
-
Разумную реакцию на ошибку.
-
Exception safety (транзакционность/базовую гарантию).
-
Процесс.
-
Поток.
-
Запрос пользователя.
-
Мягкими - граница библиотеки. Обрабатывать ошибки не умеем, но нужно завернуть в свои типы для сокрытия реализации.
-
Жесткими - ошибки ловим и обрабатываем. Если ловим конкретную ошибку на обработку, то возможно, что её можно обработать сразу после возникновения, на ближайшей границе.
-
Ниже:
-
Ближе к контексту возникновения ошибки, лучше понимаем, что произошло.
-
Плохо понимаем, что делать с ней.
-
Любая попытка обработки - отклонение от стандартного плана исполнения, протестировать вряд ли удасться.
-
-
Выше:
-
Проще вернуть программу в консистентное состояние.
-
Программа проходит больший пусть в рамках стандартного плана исполнения.
-
Теряем часть информации об ошибке и обстоятельствах её возникновения.
-
-
Для ошибок программиста - паника.
-
Для всего остального - возвращаемые значения
// 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)?)
}
...
}
-
Для не ошибок - типы-суммы.
-
Для остального - исключения. IO ошибки, как и ошибки программиста, обычно поднимают высоко по стеку для централизованной обработки.
-
Для ошибок программиста используют стандартные исключения.
-
Для остальных - исключения из собственных иерархий.
-
Никаких checked exceptions!
val number: Int = string.toInt()
return number + 5
// vs
val number: Int? = string.toIntOrNull()
return number?.plus(5) ?: 42
-
Для ошибок программиста
_|_
. -
Для остальных - монады
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