Об ошибках

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")
}

Обычно паника случается опять-таки при обращении к нулевому указателю и делению на нуль. Но можно выбросить её самостоятельно. И можно обработать на любом уровне стека. А сам объект паники, это тоже любой объект.

Pokemon Excepton Handling

Налицо наличие двух типов ошибок.

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

Вторые — это неожиданные ошибки. Нулевой указатель. Нехватка памяти. Это либо ошибки программиста, программа оказалась в том состоянии, которое не было предусмотрено, и опасно продолжать работу. Либо фатальные системные ошибки, когда тоже опасно продолжать работу.

Об ожидаемых ошибках нужно знать заранее. Это часть контракта. Специальное возвращаемое значение, типа error или Result, вполне удовлетворяет этому требованию. Также как и checked exception.

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

Причём я замечаю, что в 95% случаев важен лишь факт наличия или отсутствия ошибки, не важно, какая именно ошибка произошла. И не так уж и нужно сопоставление по типу исключения в блоке catch, вполне достаточно простого сравнения с nil.

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

А вот неожиданные системные ошибки как раз и должны ходить по стеку. Потому что в 98% случаев всё что можно сделать, так это аварийно завершить выполнение программы.

Остальные случаи — это сложные фреймворки, типа серверов приложений, которые сами следят за выполнением пользовательского кода, и ошибки в одном куске не должны рушить другие выполняющиеся куски. В веб фреймворках любая ошибка при выполнении запроса вернёт клиенту 500-ю ошибку, но не сломает обработку других запросов.

Checked exceptions умерли. Да здравствует Result. Не могу сказать, что один из них лучше другого. Вон, в Go даже испугались, что текущая обработка ошибок слишком многословна, и думают над упрощениями.

Ясно только, что концепция «пусть выбрасывается что угодно, а мы забудем словить», которая работает по умолчанию в C++ и Kotlin, может быть опасной.