Неполная, неточная и наполовину выдуманная история исключений

a46c6e22b651a4289feb55080cbb22f6.png

Начнем издалека

Давным-давно, когда деревья были маленькие, дискеты большие, а трава зеленая, все писали на языках низкого уровня. В этих языках всё было целыми числами. Переменные были числами, массивы были и структуры были просто адресами (числами) и смещениями (тоже числами). Даже если указывали тип данных, то он определял только размер ячейки памяти для значения.

В эти старые добрые времена было очень мало причин почему программа не могла продолжить работу. Например деление на ноль, неправильное обращение к памяти (например обращение по адресу равному нулю) или неправильная инструкция процессора (это когда уже совсем все сломалось). Если что-то такое происходило операционная система без капли смущения убивала вашу программу.

Чтобы программа не падала, а выдавала осмысленное сообщение и давала возможность продолжить работу, надо было добавить проверку.

Например так (примеры на C):

if (divisor != 0) {
  x = dividend / divisor; 
} else {
  // Сделать что-то осмысленное
}

Или так:

m = malloc(100); // Выдели мне 100 байт памяти
if (m)  {// Если m не равно нулю, память выделилась успешно
  *m = 0; // Обращаемся к памяти по указателю m
} else {
  // выдаем осмысленное сообщение об ошибке, чтобы легче было отлаживать
}

Таким же образом обрабатывали ошибки других функций операционной системы:

int fd = fopen(path, "r+");
if (fd) { // Если fd не равен 0, то файл открылся
  // Читаем и пишем файл
} else {
  // выдаем осмысленное сообщение об ошибке или повторяем
}

Такие проверки писать очень неудобно. На каждый вызов функции ОС надо было написать несколько строк кода. Причем для разных функций проверки разные. Одни функции возвращают 0 в случае ошибки. Другие наоборот возвращают 0 при успешном завершении. Третьи для ошибки могут использовать код -1. Конечно есть библиотеки функций и макросов, которые сворачивают обработку ошибок до одной строки и в случае ошибки завершают программу с указанием где и почему упало.

Программисты часто ленились и просто игнорировали коды ошибок. Код начинал работать непредсказуемо.

Тем не менее такой подход вполне рабочий, linux написан таким образом. Да и для языка низкого уровня сложно придумать что-то более подходящее.

Языки высокого уровня

В языках высокого уровня типы данных определяют не только размер переменной, но и возможные операции с этой переменной.

Можно написать так (пример на C++):

matrix x(3,3), y(4,4);
auto z = x + y;

И сразу возникает вопрос, а что же должно произойти в таком коде? Сложить матрицы разных размеров нельзя. Но мы при этом должны вернуть матрицу. Это исключительная ситуация. Сделать, конечно, в этой ситуации ничего нельзя, надо завершить программу с осмысленным сообщением об ошибке.

Другой пример:

int sum;
double rate;
ifstream file(path); 
if(file.is_open()) {    // Оставили проверку кода завершения
  file >> sum >>  rate; // Чтение двух чисел из текстового файла
}

Если в файле не окажется двух чисел, то какие значения должны получить sum и rate? Снова исключительная ситуация. Можно предложить программистам после операций чтения проверять код последней ошибки в объекте file, но этого точно никто делать не будет. В данном случае завершать программу с ошибкой нельзя, возможно надо дать пользователю выбрать другой path.

Вам могло показаться, что исключительные ситуации возникают только при перегрузке операторов. Исключительные ситуации так же могут возникнуть в конструкторе. В конструкторе класса вы можете вернуть только объект, поэтому сложно сделать код завершения, который программист не проигнорирует.

Для того, чтобы программист не мог проигнорировать исключительную ситуацию, но при этом мог сделать так, чтобы программа не упала, в языках высокого уровня придумали выбрасывать исключения (exceptions).

Как исключения выбрасываются и ловятся

В большинстве языков обработчики исключений добавляются в код с помощью ключевых слов try\catch\finally, а в некоторых языка, например C++, есть автоматически обработчики при выходе из области для вызова деструкторов объектов (RAII). В catch обычно указывается тип исключения, которое надо перехватывать. Компилятор формирует таблицы обработчиков, записывая в исполняемый файл таблицы с адресами начала и конца блока try и ссылки на блоки catch и fnally.

Исключения выбрасываются с помощью ключевого слова throw. В этот момент вызывается функция рантайма языка, которая по таблицам обработчиков проверяет есть ли обработчик исключения в вашем коде. Если обработчик не найден или он не смог обработать исключение, то идет раскрутка стека. Рантайм переходит к вызывающей функции и ищет обработчик для нее и так по кругу.

Если обработчик не был найден после раскрутки всего стека, то программа завершается. Обработчики finally не останавливают процесс раскрутки стека, а только выполняют код (чаще всего освобождают ресурсы). Если найден обработчик catch, который сможет обработать исключение (для этого используется проверка типа) и вернуться к нормальному выполнению, то программа продолжится после блока этого обработчика.

Проблемы обработки исключений

Первая проблема в том, что поиск по таблицам, раскрутка стека, проверка типа исключения обработчика — дорогие операции. Чем более сложный код — тем дороже. Чем дальше catch и finally от throw — тем дороже.

Хорошо что обработка исключений практически не влияет на код, который исключения не выбрасывает. Но если исключение появляется, то «проигравший платит за всё». Поэтому чем меньше мы бросаем исключений, тем лучше.

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

Пример, был такой код (в этот раз на C#):

MyClass RetryOperation(string url) {
  while (true) {
    try {
      return Operation(url);    
    } catch (WebException) {
      Thread.Sleep(5000);
    }
  }  
}

MyClass Operation(string url) {
  var json = httpClient.Get(url);
  return JsonSerializer.Deserialize(json);
}

Этот код прекрасно работал, но со временем функция Operation разрослась дополнительными проверками и преобразованиями и уже с трудом умещалась на экране.

В один момент не очень грамотный программист добавил туда логирование ошибок:

MyClass Operation(string url) {
  MyClass obj;
  try {
    //... много кода...
    var json = httpClient.Get(url);
    //... много кода...  
    obj =  JsonSerializer.Deserialize(json);
    //... много кода...  
  } catch (Exception e) { // Перехыватывает все исключения
    Log(e.ToString());
  }
  return obj;
}

И на компьютере программиста все прекрасно работало, потому что тестовый сервис стоял на том же компьютере и всегда отвечал без ошибок. На проде, естественно, поломалось.

Конечно грамотный программист должен был перевыбросить исключение и вообще не ловить самый базовый тип исключения. И вообще не надо строить логику на исключениях. Но хотелось бы защититься по таких случаев средствами языка.

А надо ли вообще перехватывать исключения?

Вернемся к примеру с матрицами. Сложение матриц разных размеров перегруженным оператором + — ошибка программиста. Программа в принципе не должна пытаться это делать. По большому счету нет смысла кидать исключение и пытаться обрабатывать его, можно программу просто завершить.

Такие исключения получили название usage exceptions — они говорят что вы неправильно используете объект или функцию и у вас ошибка в программе. К ним относятся ошибки аргументов функции, ошибки преобразований типов, ошибки арифметических операций над сложными объектами, ошибки состояния объекта. Всякими гайдлайнами их запрещено перехватывать. В готовой программе usage exceptions не должны возникать.

Другой тип исключений — когда функция не может выполнить свою работу и вернуть осмысленное значение и это никак не связно с общением программы с внешним миром. «Ключ не найден в словаре», «Ошибка парсинга строки в число» и… я что-то больше даже не придумал. Таких исключений в базовой библиотеке немного, но их любят изобретать программисты. Назовем их application exceptions. Такие исключения рекомендуется менять на try-pattern.

// было
try {
  var x = int.Parse(s); 
  // Используем x
} catch (FormatException) {
  // Не распарсилось  
}

//стало
if(int.TryParse(s, out var x)) {
  // Используем x
} else {
  // Не распарсилось
}

Третий тип исключений нам уже знаком — это IOExeption, WebException, SocketException, SqlException и прочие исключения, которые могут возникнуть из-за того, что программа общается с внешним миром. Назовем их io exceptions.

Четвертый тип — исключения рантайма, например OutOfMemory, StackOverflow, итд Их перехватывать нет смысла, зачастую это даже невозможно.

Итого: 3 из 4 типов исключений не надо перехватывать вообще. Под вопросом только io exceptions, но если мы применим для них try-pattern, то получим коды возврата как в старом добром C.

Функциональный взгляд

В мире функционального программирования не стали придумывать исключения. Там все функции, которые могут завершиться ошибкой возвращают union-тип (or-тип), то есть тип, который может принимать значение одного из нескольких, в нашем случае двух, типов. Или результат операции, или ошибка. Записывается как Result.

Функциональное программирование последние 15–20 лет активно проникает в мейнстрим, поэтому многие современные языки обзавелись такими возможностями, плюс появились новые языки, где такие возможности уже встроены.

Сразу же сотнями появились статьи, что исключения в C++\C#\Java\Python использовать нельзя и надо срочно взять новые типы на вооружение.

Но давайте будем честными. Это те же самые коды возврата.

Пример из документации к Rust:

    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };

Сколько принципиальных отличий от примера с fopen в начале статьи вы увидели?

Более того, если у вас в одной функции будут Result и Result вы замучаетесь дружить их вместе.

Правда в функциональных языках можно использовать монадный синтаксис для работы с Result. Гипотетический код на Scala:

def authenticate(userName: String, password: String): Either[AuthenticationError, User] =
  for {
    user <- findUserByName(userName)
    _ <- checkPassword(user, password)
    _ <- checkSubscription(user)
    _ <- checkUserStatus(user)
  } yield user

Это значительно сокращает объем кода при последовательном вызове нескольких методов. И, самое главное, помогает не пропустить проверку. Но опять-таки работает только если у вам один тип ошибки.

К сожалению монадный синтаксис доступен далеко не везде: Haskell, C# (linq), F# (computational expressions), Scala… из более-менее популярных языков все. Частично можно монадный синтаксис реализовать в Python и в Kotlin. Ни в Rust, ни в Go, ни в самый последний JS, ни в C++ такой синтаксис не завезли (по крайней мене на момент написания статьи).

Как проблемы решаются в Go и Rust

Напомню что основные проблемы: как не игнорировать Result и как не писать врукопашную сотни if.

Rust и Go это два языка, появившихся примерно в одно время, оба декларируют что в них нет исключений, но имеют очень разные подходы к решению проблем.

Rust

В Rust есть специальный оператор ?, который автоматически добавляет проверку к выражению типа Result.

fn read_username_from_file() -> Result {
  let mut username = String::new();
  File::open("hello.txt")?.read_to_string(&mut username)?; //обратите внимание на знаки вопроса
  Ok(username)
}

превращается в:

fn read_username_from_file() -> Result {
  let username_file_result = File::open("hello.txt");

  let mut username_file = match username_file_result {
      Ok(file) => file,
      Err(e) => return Err(e),
  };

  let mut username = String::new();

  match username_file.read_to_string(&mut username) {
      Ok(_) => Ok(username),
      Err(e) => Err(e),
  }
}

В простых случаях, когда нам достаточно завершить программу при ошибке чтения файла можно написать:

fn main() {
  let file = File::open("hello.txt").unwrap(); // Если файл не открылся программа просто упадет
  let mut username = String::new();
  file.read_to_string(&mut username).unwrap();
}

А чтобы разработчик не забыл проверить результат есть макрос #[must_use]. С ним компилятор будет ругаться если вы ничего не сделаете со значением типа Result.

Go

В языке Go основные проблемы не решаются никак. Совсем никак.

func check(e error) {
    if e != nil {
        panic(e)
    }
}

f, err := os.Open("/tmp/dat")
check(err) 

_, err = f.Write(p0[a:b])
check(err)
_, err = f.Write(p1[c:d])
check(err)
_, err = f.Write(p2[e:f])
check(err)

Сразу хочется написать так:

f := check(os.Open("/tmp/dat"))

И это можно сделать, но тогда надо написать столько разных функций check сколько типов возвращаемых значений, так как генериков нет. Нет, это не шутка.

UPD. Генерики в Go появились спустя 5 лет, можно ли написать универсальную функцию check — надо уточнить.

В Go рекомендую использовать библиотеки с типами обертками, которые обработку ошибка прячут внутри себя. Чтобы потом писать так:

b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
    return b.Flush()
}

Нет готовой подходящей — пиши свою. Сделать обобщенную обертку нельзя, генериков нет. А если вы просто проигнорите err, то вам никто ничего не скажет.

Это все при том, что в Go есть обработка исключений. Работает почти также как в C++\C#\Java, только ключевые слова panic\defer\recover, чтобы никто не догадался.

Заключение

Мы рассмотрели много способов обработки ошибок начиная от языков низкого уровня, заканчивая функциональщиной и строгой системой как в Rust, а заодно посмотрели как это не надо делать, на примере Go.

Подход Rust интересен для низкоуровневого кода. Он по факту делает то же самое, что делается на языке низкого уровня, но при этом убирает много ошибок программиста. Но он очень требователен к программисту в плане обработки ошибок.

Для высокоуровневого кода я бы пока еще предпочел классический подход C#\Java, но при этом кидать исключения как можно реже, а перехватывать еще реже. Возможно на самом высоком уровне, чтобы сказать пользователю «что-то пошло не так, повторите операцию» и записать в лог. По сути для этого даже специально писать ничего не надо. Все нужно уже встроено во фреймворки, главное не испортить.

Возможно тот же Rust получит возможность ограниченно использовать исключения и вберет в себя лучшее разных подходов.

Какой способ лучше — решать вам.

© Habrahabr.ru