Об ошибках
2019-06-30
В C всё просто, но непоследовательно. Обычно функции делают что-то сами по себе и возвращают указатель на структуру, которая является результатом работы.
FILE *fp;
if ((fp = fopen("test", "w")) == NULL) {
printf("Cannot open file.\n");
exit(1);
}
int *arr = malloc(sizeof(*arr));
if(arr == NULL)
{
printf("Memory allocation failed");
return;
}
Нулевой указатель является
признаком того,
что при выполнении функции произошла ошибка.
И на NULL
нужно проверять.
Или же функции работают со структурами, переданные по указателю. А возвращают некое целое число, код возврата. Причём иногда этот код возврата является номером некоторой внутренней структуры, тогда его называют handle или descriptor.
int fd = open("testfile.txt", O_RDONLY);
if (fd < 0) {
printf("Failed to open.\n");
return 1;
}
int nread;
char buffer[128];
nread = read(fd, buffer, 128);
if (nread == -1) {
printf("Failed to read.\n");
return 1;
}
Собственно,
про этот самый RETURN VALUE
очень подробно расписывается в манах по каждой функции.
Чаще всего об ошибке сигнализирует значение -1
,
ну или NULL
.
Но у каждой функции всё может быть индивидуально.
Часто при этом выставляется значение errno
.
Это такая глобальная целая переменная,
в которую помещается более детальный код ошибки.
В результате безопасный код на С — это тщательная проверка возвращаемого значения после каждого вызова. И никогда не узнаешь, что означает полученное число, пока не почитаешь документацию на функцию.
Собственно,
эта концепция кода возврата
действует и для юниксовых процессов.
main()
ведь тоже возвращает int
.
И здесь уже 0
означает успешное завершение.
А что-то другое,
чаще положительное значение,
означает ошибку.
Но что произойдёт, если мы обратимся по нулевому указателю? Или поделим на нуль?
Сам по себе язык C объявляет эти случаи неопределённым поведением. То есть всё зависит от конкретной реализации компилятора.
Обращение по нулевому указателю, если его пропустил компилятор, и оно случилось в рантайме, как правило, становится обращением по адресу, к которому текущему процессу запрещено обращаться. Это вызывает сегфолт, и в юниксах процесс завершается по соответствующему сигналу.
Целочисленное деление на нуль на уровне процессора вызывает соответствующее прерывание, а на уровне ОС (Unix) отправку соответствующего сигнала, и аварийное завершение процесса.
Обратите внимание, появление какой-либо непредусмотренной страшной ошибки, типа обращение к нулевому указателю или деление на нуль, на аппаратном уровне вызывает прерывание. Прерывание означает, что текущий поток выполнения прерывается, а управление передаётся на адрес заранее зарегистрированного обработчика прерываний.
Аналогично на урове ОС. Аварийная ситуация приводит к тому, что процесс получает сигнал. Но сигнал (кроме KILL и STOP), тоже можно обработать, если заранее задать обработчик.
Вот эта вот концепция: ошибаемся в одном месте, а обрабатываем ошибку в другом месте — в языках высокого уровня превратилась в исключения.
Исключения,
это те самые try-catch
в C++, Java, C# и туче других языков.
try {
file.open("test.txt");
while (!file.eof()) file.get();
file.close();
}
catch (std::ifstream::failure e) {
std::cerr << "Exception opening/reading/closing file\n";
}
Исключения несколько сложнее, чем просто обработчики сигналов.
Исключения завязаны на стек вызовов. Если исключение не обработано в текущей функции, оно может быть обработано в вызывающей функции, и так далее, пока не дойдём до умолчательного обработчика, убивающего процесс.
Исключения завязаны на классы и наследование.
Обработчики исключения (блоки catch
)
обычно «фильтруют» исключения по их принадлежности к определённому классу,
то есть охватывают только определённую ветку
дерева наследования классов-исключений.
Собственно,
сам объект-исключение,
который приходит в обработчик,
может быть сильно больше,
чем просто число
(хотя в C++ может действительно быть просто числом).
Часто там есть и стек вызовов,
и всё что угодно было туда поместить разработчику.
Вызов функции в C++ — как коробка шоколадных конфет. Никогда не знаешь, какое исключение выпадет.
Дело в том, что разработчик не обязан сообщать о том, какие исключения выбрасывает его функция. Можно было объявить список исключений, которые выбрасывает данная функция.
double myfunction (char param) throw (int);
Но декларация этого списка
не требует для вызывающего кода
в обязательном порядке обработать указанные исключения.
Да и в последних версиях С++ эту конструкцию throw
выкинули.
С точки зрения контрактного программирования знать, какие в точности исключения может выбросить функция, очень полезно. Видимо, этим и руководствовались создатели Java, когда добавили в язык checked exceptions.
В Java, если ваш метод объявляет, что он выбрасывает определённые checked исключения, то компилятор требует, чтобы это исключение было обработано прямо в месте вызова. Вы не можете проигнорировать checked exception.
public void myMethod() throws MyException {
//...
}
//...
try {
myMethod();
} catch (MyException e) {
//...
}
На практике это приводит к тому,
что исключения приходится оборачивать.
Контракт требует,
чтобы ваш метод выбрасывал только определённые исключения.
Но если вам для реализации этого метода,
например,
нужно делать ввод-вывод,
то у вас возникает IOException
.
Его-то и нужно обернуть.
public void myMethod() throws MyException {
try {
this.reader.read()
//...
} catch (IOException ioe) {
throw new MyException(ioe)
}
}
С точки зрения контрактного программирования такое оборачивание исключений совершенно правильно. Независимо от деталей реализации наш метод должен выбрасывать только те исключения, что перечислены в контракте. И никаких других.
Однако, в Java сохранились unchecked exceptions. В стиле C++. Эти исключения не требуют ни объявления, ни отлова. В языке они используются для выражения таких ситуаций, которые никак не зависят от контракта и могут возникнуть в любом месте кода. Те самые обращение по нулевому указателю и деление на нуль. А также ошибки выделения памяти.
Судя по всему,
явовый эксперимент с checked exceptions
провалился.
Программистам надоело писать try-catch
блоки?
Java был единственным языком,
где такие эксепшены завелись.
Но в новых классах,
добавляемых в новые стандартные Java API,
используются только unchecked exceptions.
И в Kotlin checked exceptions выпилили.
Кстати, сами эти checked exceptions — это фича компилятора языка Java. На уровне JVM все исключения равны и никакой проверки на обязательность их отлова не производится.
Также исключения критикуют за нестабильность. Невозможно достоверно предсказать, в каком месте кода произойдёт выход по исключению, и где это исключение будет обработано. Точнее путей выполнения кода с исключениями становится слишком много. Подробности можно узнать, например, во мнении разработчика ZeroMQ.
Возможно, по этим причинам в некоторых более поздних языках отказались от исключений в пользу явной обработки ошибок. В некотором смысле это шаг назад к C. Но не совсем.
Например, в Go.
В Go функции могут возвращать более одного значения.
И действует соглашение,
что,
помимо собственно результатов,
функция,
которая может завершиться с ошибкой,
последним из результатов
возвращает объект ошибки
(стандартный интерфейс error
)
или nil
.
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
В Go обязательно нужно получить все возвращаемые значения функции.
И обязательно нужно использовать переменную,
хотя бы,
чтобы проверить её на nil
.
В результате отвертеться от проверки на ошибку
сразу после вызова функции
почти невозможно.
Проигнорировать err
можно только явно.
f, _ := os.Open("filename.ext")
В Go отсутствует наследование.
Поэтому проверка на nil
,
это, как правило,
единственная проверка.
Иногда,
впрочем,
сравнивают ошибку
с каким-то конкретным предопределённым значением.
В Rust используется похожий, но более строго типизированный подход. Монада Result.
let mut file = File::create("valuable_data.txt").unwrap();
assert!(file.write_all(b"important message").is_ok());
Result
содержит
либо конкретное типизированное значение функции Ok
,
либо типизированную ошибку Err
.
Причём Err
нужно обязательно проверить,
иначе компилятор выдадет ворнинг.
Забавно,
что ошибку можно и не проверять,
а выбросить выше по стеку.
Если ваша функция тоже возвращает Result
.
Это уже больше похоже на исключения.
Но проброс нужно сделать явно,
как с checked exception,
значком ?
.
fn write_message() -> io::Result<()> {
let mut file = File::create("valuable_data.txt")?;
file.write_all(b"important message")?;
Ok(())
}
В Go тоже есть способ прокидывать ошибки через стек. Это называется panic. Хоть по синтаксису это больше похоже на какие-нибудь обработчики сигналов, по сути это прям совсем исключение.
func recoverName() {
if r := recover(); r != nil {
fmt.Println("recovered from ", r)
}
}
func fullName(firstName *string, lastName *string) {
defer recoverName()
if firstName == nil {
panic("runtime error: first name cannot be nil")
}
if lastName == nil {
panic("runtime error: last name cannot be nil")
}
fmt.Printf("%s %s\n", *firstName, *lastName)
fmt.Println("returned normally from fullName")
}
Обычно паника случается опять-таки при обращении к нулевому указателю и делению на нуль. Но можно выбросить её самостоятельно. И можно обработать на любом уровне стека. А сам объект паники, это тоже любой объект.
Налицо наличие двух типов ошибок.
Первые — это ожидаемые ошибки. При вводе-выводе, при неправильных аргументах или неправильном состоянии. Они сигнализируют вызывающей стороне, что что-то пошло не так, и данная операция прямо сейчас не может быть выполнена. Вполне возможно, что оно заработает позднее. Или если исправить ошибки в аргументах.
Вторые — это неожиданные ошибки. Нулевой указатель. Нехватка памяти. Это либо ошибки программиста, программа оказалась в том состоянии, которое не было предусмотрено, и опасно продолжать работу. Либо фатальные системные ошибки, когда тоже опасно продолжать работу.
Об ожидаемых ошибках нужно знать заранее.
Это часть контракта.
Специальное возвращаемое значение,
типа error
или Result
,
вполне удовлетворяет этому требованию.
Также как и checked exception.
И такие ошибки должны быть обработаны
непосредственно сразу после совершения действия,
которое может завершиться с ошибкой.
И по умолчанию такая проверка должна быть обязательной.
Игнорирование проверки должно быть явным действием программиста.
error
, Result
и checked exceptions
так и работают.
Причём я замечаю,
что в 95% случаев важен лишь факт наличия или отсутствия ошибки,
не важно,
какая именно ошибка произошла.
И не так уж и нужно сопоставление по типу исключения в блоке catch
,
вполне достаточно простого сравнения с nil
.
В остальных редких случаях,
например,
когда нужно точно знать,
почему не удалась сетевая операция,
из-за неотрезолвенного хоста,
или отвалилась по таймауту,
можно и просто проанализировать объект ошибки.
Примитивный паттерн матчинг
в catch
не обязателен.
А вот неожиданные системные ошибки как раз и должны ходить по стеку. Потому что в 98% случаев всё что можно сделать, так это аварийно завершить выполнение программы.
Остальные случаи — это сложные фреймворки, типа серверов приложений, которые сами следят за выполнением пользовательского кода, и ошибки в одном куске не должны рушить другие выполняющиеся куски. В веб фреймворках любая ошибка при выполнении запроса вернёт клиенту 500-ю ошибку, но не сломает обработку других запросов.
Checked exceptions умерли.
Да здравствует Result
.
Не могу сказать,
что один из них лучше другого.
Вон,
в Go даже испугались,
что текущая обработка ошибок слишком многословна,
и думают над упрощениями.
Ясно только, что концепция «пусть выбрасывается что угодно, а мы забудем словить», которая работает по умолчанию в C++ и Kotlin, может быть опасной.