Return код ошибки

Время на прочтение
7 мин

Количество просмотров 20K

Судя по всему, неделя исключений на хабре успешно наступила :). Накопив достаточную «подушку» кармы чтобы не бояться уйти в минус, я, пожалуй, тоже выскажу свое мнение по данному вопросу. Сразу оговорюсь, что мнение сугубо личное, основанное на небольшом практическом опыте коммерческой разработки: C++, Objective-C, C#, Java, Python, Ruby.

Что такое ошибка?

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

  1. Самое простое что в программе может случиться — это сбой операционной системы или железа. Не сработавший системный вызов CreateEvent() или pthread_mutex_lock(), деление на ноль, мусор в результатах системного вызова — все это может случиться по разным причинам, начиная от вышедшего из строя железа и заканчивая вирусами в системе, но как правило от нас и нашей программы это не очень зависит.
  2. Чуть более сложная ситуация — это отсутствие нужных нам ресурсов. Неожиданно может закончиться память, хэндлы, файловые дескрипторы. Может не быть прав на запись или чтение нужных файлов. Пайп может не открыться. Или наоборот — не закрыться. Доступ к базе данных может быть — а может и не быть. Такая ситуация уже может быть вызвана как нашей программой (слишком много памяти восхотелось) так и нестабильностью системы (вирусу слишком много памяти восхотелось).
  3. А самая распространенная ситуация — это ошибка в логике программы или взаимодействия ее частей. Мы пытаемся удалить несуществующий элемент списка, вызывать метод с неверными аргументами, многопоточно выполнить неатомарную операцию. Как правило это приводит или к некорректному поведению программы («тулбар исчез») или к ее краху с access violation / unhandled exception.

Как видите, много всего разного и нехорошего может произойти — а ведь это далеко не полный список :). А что делать программисту? Тут, на мой взгляд, перед нами встает очень интересный и важный вопрос — как именно нашей программе реагировать на ту или иную ошибку? Пожалуй сейчас я еще раз напомню, что излагаю свое сугубо личное мнение. И скажу следующее — как именно реагировать на ошибку целиком зависит от конкретной программы. Если у нас закончилась память в драйвере — мы должны любой ценой выжить, чтобы пользователь не получил синего экрана смерти. Если же у нас закончилась память в игрушке типа веселая ферма — то имеет смысл упасть, извиниться и попросить отправить багрепорт разработчику. Системный сервис, призванный крутиться многие месяцы без перезагрузки, должен с пониманием отнестись к ошибке CreateEvent(). Та жа ошибка в прикладной программе типа Photoshop означает что скорее всего система через секунду умрет, и лучше честно упасть, нежели попытаться проглотить ошибку, дать пользователю сохранить файл и благополучно его испортить из-за последующего сбоя во время записи. Следовательно ошибки мы можем делить на ожидаемые и неожиданные. Для разных программ и разных требований одни и те же ошибки могут считаться как ожидаемыми, так и неожиданными. С ожидаемыми ошибками мы как-то работаем. Не получилось открыть файл — говорим об этом пользователю и продолжаем работу. Не удалось выделить память для загрузки туда гигабайтного файла — говорим об этом пользователю и продолжаем работу. С неожиданными ошибками мы в большинстве случаев не работаем. Закончилась память при попытке выделить двадцать байт для создания объекта — падаем. Не создался системный объект которых на всю программу три штуки — падаем. Не читается системный пайп который по спецификации должен читаться? Лучше упасть, чем оставить программу в нестабильном состоянии и потом испортить пользователю данные. Программу он если что перезапустит, а вот за испорченный файл возненавидит до конца дней своих. А для серьезных случаев есть автосейв и перезапускающий нас ежели чего watchdog.

Что было до исключений?

В эпоху расцвета процедурного программирования синтаксис работы с ошибками был тривиален и основывался на том, что вернула функция. Если функция возвращала TRUE — все хорошо, если же FALSE — то произошла ошибка. При этом сразу выделились два подхода к работе с ошибками:

  • Подход два в одном — функция возвращает FALSE или нулевой указатель как для ожидаемой, так и для неожиданной ошибки. Такой подход как правило применялся в API общего назначения и коде пользовательских программ, когда большую часть ошибок можно было смело считать фатальными и падать. Для тех редких случаев когда делить было все же нужно использовалась некая дополнительная машинерия вида GetLastError(). Фрагмент кода того времени, копирующего данные из одного файла в другой и возвращающего ошибку в случае возникновения любых проблем:
    BOOL Copy( CHAR* sname, CHAR* dname )
    {
      FILE *sfile = 0, *dfile = 0;
      void* mem = 0;
      UINT32 size = 0, written = 0;
      BOOL ret = FALSE;
    
      sfile = fopen( sname, "rb" );
      if( ! sfile ) goto cleanup;
      dfile = fopen( dname, "wb" );
      if( ! dfile ) goto cleanup;
      mem = malloc( F_CHUNK_SIZE );
      if( ! mem ) goto cleanup;
      do
      {
        size = fread( sfile, mem, F_CHUNK_SIZE );
        written = fwrite( dfile, mem, size );
        if( size != written ) goto cleanup;
      }
      while( size )
      ret = TRUE;
    cleanup: // Аналог деструктора.
      if( sfile) fclose( sfile );
      if( dfile) fclose( dfile );
      if( mem ) free( mem );
      return ret; // Ожидаемая ошибка.
    }
    

  • Подход разделения ошибок, при котором функция возвращает FALSE в случае неожиданной ошибки, а ожидаемую ошибку возвращает отдельным возвращаемым значением (в примере это error), если нужно. Такой подход применялся в более надежном коде, например apache, и подразумевал разделение на ожидаемые ошибки (файл не получилось открыть потому что его нет) и неожиданные (файл не получилось открыть потому, что закончилась память и не получилось выделить 20 байт чтобы скопировать строку с именем). Фрагмент того же код, но уже разделяющего неожиданную ошибку (возврат FALSE) и ожидаемую (возврат HANDLE).
    BOOL Copy( CHAR* sname, CHAR* dname, OUT HANDLE* error )
    {
      HANDLE sfile = 0, dfile = 0, data = 0;
      UINT32 size = 0;
    
      ENSURE( PoolAlloc() ); // Макрос обеспечивает обработку неожиданной ошибки.
      ENSURE( FileOpen( sname, OUT& sfile, OUT error ) );
      REQUIRE( SUCCESS( error ) ); // Макрос обеспечивает обработку ожидаемой ошибки.
      ENSURE( FileOpen( dname, OUT& dfile, OUT error ) );
      REQUIRE( SUCCESS( error ) );
      ENSURE( MemAlloc( OUT& data ) );
      REQUIRE( SUCCESS( error ) );
      do
      {
        ENSURE( FileRead( sfile, F_CHUNK_SIZE, OUT& data, OUT error ) );
        REQUIRE( SUCCESS( error ) );
        ENSURE( FileWrite( dfile, & data ) );
        REQUIRE( SUCCESS( error ) );
        ENSURE( MemGetSize( OUT& size ) )
      }
      while( size );
      ENSURE( PoolFree() ); // Пул обеспечивает аналог деструкторов и RAII.
      return TRUE;
    }
    

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

Что стало после введения исключений

Давайте возьмем код выше и посмотрим, как он трансформировался после добавления ООП в синтаксис языков программирования. Конструирование и уничтожение объектов (fopen, fclose) стало конструкторами и деструкторами. Переброс неожиданной ошибки (BOOL ret в первом примере, макрос ENSURE во втором) однозначно стал исключением.

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

  • Подход только исключения — ожидаемые и неожиданные ошибки — это разные типы исключений.
    void Copy( string sname, string dname )
    {
      file source( sname );
      file destination( sname );
      source.open( "rb" );
      destination.open( "wb" );
      data bytes;
      do
      {
        bytes = source.read( F_CHUNK_SIZE );
        destination.write( bytes )
      }
      while( bytes.size() )
    }
    

  • Комбинированный подход — использование исключений для неожиданных ошибок и кодов возврата / nullable типов для ожидаемых:
    bool Copy( string sname, string dname )
    {
      file source( sname );
      file destination( sname );
      if( ! source.open( "rb" ) || ! destination.open( "wb" ) ) return false;
      data bytes;
      do
      {
        bytes = source.read( F_CHUNK_SIZE );
        if( bytes.isValid() )
        {
          if( ! destination.write( bytes ) ) return false;
        }
      }
      while( bytes.isValid() && bytes.size() )
    }
    

Почему выжили коды возврата?

Здесь я еще раз напомню, что высказываю свое личное мнение и открыт к обсуждению :). Итак, если внимательно посмотреть на два приведенных выше фрагмента кода то становится не совсем понятно почему выжил второй. Кода в нем объективно больше. Выглядит менее красиво. Если функция возвращает объект — то использовать коды возврата совсем неудобно. Вопрос — почему коды возврата вообще выжили в языках с поддержкой объектно-ориентированного программирования и исключений на уровне синтаксиса? Что я могу по этому поводу сказать:

  • Первые реализации исключений, особенно в C++, были не очень удобны для ежедневного использования. Например, бросание исключения во время обработки другого исключения приводил к завершению программы. Или же бросание исключения в конструкторе приводило к тому, что деструктор не вызывался.
  • Разработчикам API забыли объяснить для чего нужны исключения. В результате первое время не было даже деления на ожидаемые (checked) и неожиданные (unchecked), а API комбинировали как исключения, так и коды возврата.
  • В большинстве языков для исключений забыли добавить семантику «игнорировать ожидаемую ошибку». В результате на практике код, использующий исключения как для ожидаемых так и для неожиданных ошибок, с невероятной скоростью обрастал try и catch везде, где только можно.

Выводы

Что бы я хотел резюмировать. На мой взгляд, большинство проблем с исключениями были вызваны первыми, не очень удачными реализациями — особенно в C++. Так что выбор «использовать только коды возврата», «использовать исключения для неожиданных ошибок и коды возврата для ожидаемых» или «использовать исключения для всего» по большей части имеется только для C++. Надеюсь, мой краткий рассказ о причинах появления исключений в современных языках программирования поможет разработчикам чуть лучше ориентироваться в современных API и замечать места, где авторы использую исключения некорректно. Понимание какие из ошибок мы считаем для нашей программы ожидаемыми, какие — неожиданные и как это оптимальным образом ложится на предоставляемую языком и API модель исключений позволяет писать простой, понятный и внимательно следящий за ошибками код.

«Код ошибки», «код возврата» и миллион терминов

 bash, помогите разобраться, терминология


0

1

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

Думал, что в баше есть только один термин- return value, который означает что программа возвращает по завершению: 0, 1 и т.д.

Но тут зашел в вики, и там куча статей по программированию и возвращаемым штукам.

  1. error code

  2. return value

  3. return code

  4. return status

  5. exit code

  6. result code

Есть ли хоть какая-то разница, едрить его налево? Есть ли смысл париться?

  • Ссылка

Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.

В C++ различают ошибки времени компиляции и ошибки времени выполнения. Ошибки первого типа обнаруживает компилятор до запуска программы. К ним относятся, например, синтаксические ошибки в коде. Ошибки второго типа проявляются при запуске программы. Примеры ошибок времени выполнения: ввод некорректных данных, некорректная работа с памятью, недостаток места на диске и т. д. Часто такие ошибки могут привести к неопределённому поведению программы.

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

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

Рассмотрим функцию, которая считывает со стандартного потока возраст и возвращает его вызывающей стороне. Добавим в функцию проверку корректности возраста: он должен находиться в диапазоне от 0 до 128 лет. Предположим, что повторный ввод возраста в случае ошибки не предусмотрен.

int ReadAge() {
    int age;
    std::cin >> age;
    if (age < 0 || age >= 128) {
        // Что вернуть в этом случае?
    }
    return age;
}

Что вернуть в случае некорректного возраста? Можно было бы, например, договориться, что в этом случае функция возвращает ноль. Но тогда похожая проверка должна быть и в месте вызова функции:

int main() {
    if (int age = ReadAge(); age == 0) {
        // Произошла ошибка
    } else {
        // Работаем с возрастом age
    }
}

Такая проверка неудобна. Более того, нет никакой гарантии, что в вызывающей функции программист вообще её напишет. Фактически мы тут выбрали некоторое значение функции (ноль), обозначающее ошибку. Это пример подхода к обработке ошибок через коды возврата. Другим примером такого подхода является хорошо знакомая нам функция main. Только она должна возвращать ноль при успешном завершении и что-либо ненулевое в случае ошибки.

Другим способом сообщить об обнаруженной ошибке являются исключения. С каждым сгенерированным исключением связан некоторый объект, который как-то описывает ошибку. Таким объектом может быть что угодно — даже целое число или строка. Но обычно для описания ошибки заводят специальный класс и генерируют объект этого класса:

#include <iostream>

struct WrongAgeException {
    int age;
};

int ReadAge() {
    int age;
    std::cin >> age;
    if (age < 0 || age >= 128) {
        throw WrongAgeException(age);
    }
    return age;
}

Здесь в случае ошибки оператор throw генерирует исключение, которое представлено временным объектом типа WrongAgeException. В этом объекте сохранён для контекста текущий неправильный возраст age. Функция досрочно завершает работу: у неё нет возможности обработать эту ошибку, и она должна сообщить о ней наружу. Поток управления возвращается в то место, откуда функция была вызвана. Там исключение может быть перехвачено и обработано.

Перехват исключения

Мы вызывали нашу функцию ReadAge из функции main. Обработать ошибку в месте вызова можно с помощью блока try/catch:

int main() {
    try {
        age = ReadAge();  // может сгенерировать исключение
        // Работаем с возрастом age
    } catch (const WrongAgeException& ex) {  // ловим объект исключения
        std::cerr << "Age is not correct: " << ex.age << "n";
        return 1;  // выходим из функции main с ненулевым кодом возврата
    }
    // ...
}

Мы знаем заранее, что функция ReadAge может сгенерировать исключение типа WrongAgeException. Поэтому мы оборачиваем вызов этой функции в блок try. Если происходит исключение, для него подбирается подходящий catch-обработчик. Таких обработчиков может быть несколько. Можно смотреть на них как на набор перегруженных функций от одного аргумента — объекта исключения. Выбирается первый подходящий по типу обработчик и выполняется его код. Если же ни один обработчик не подходит по типу, то исключение считается необработанным. В этом случае оно пробрасывается дальше по стеку — туда, откуда была вызвана текущая функция. А если обработчик не найдётся даже в функции main, то программа аварийно завершается.

Усложним немного наш пример, чтобы из функции ReadAge могли вылетать исключения разных типов. Сейчас мы проверяем только значение возраста, считая, что на вход поступило число. Но предположим, что поток ввода досрочно оборвался, или на входе была строка вместо числа. В таком случае конструкция std::cin >> age никак не изменит переменную age, а лишь возведёт специальный флаг ошибки в объекте std::cin. Наша переменная age останется непроинициализированной: в ней будет лежать неопределённый мусор. Можно было бы явно проверить этот флаг в объекте std::cin, но мы вместо этого включим режим генерации исключений при таких ошибках ввода:

int ReadAge() {
    std::cin.exceptions(std::istream::failbit);
    int age;
    std::cin >> age;
    if (age < 0 || age >= 128) {
        throw WrongAgeException(age);
    }
    return age;
}

Теперь ошибка чтения в операторе >> у потока ввода будет приводить к исключению типа std::istream::failure. Функция ReadAge его не обрабатывает. Поэтому такое исключение покинет пределы этой функции. Поймаем его в функции main:

int main() {
    try {
        age = ReadAge();  // может сгенерировать исключения разных типов
        // Работаем с возрастом age
    } catch (const WrongAgeException& ex) {
        std::cerr << "Age is not correct: " << ex.age << "n";
        return 1;
    } catch (const std::istream::failure& ex) {
        std::cerr << "Failed to read age: " << ex.what() << "n";
        return 1;
    } catch (...) {
        std::cerr << "Some other exceptionn";
        return 1;
    }
    // ...
}

При обработке мы воспользовались функцией ex.what у исключения типа std::istream::failure. Такие функции есть у всех исключений стандартной библиотеки: они возвращают текстовое описание ошибки.

Обратите внимание на третий catch с многоточием. Такой блок, если он присутствует, будет перехватывать любые исключения, не перехваченные ранее.

Исключения стандартной библиотеки

Функции и классы стандартной библиотеки в некоторых ситуациях генерируют исключения особых типов. Все такие типы выстроены в иерархию наследования от базового класса std::exception. Иерархия классов позволяет писать обработчик catch сразу на группу ошибок, которые представлены базовым классом: std::logic_error, std::runtime_error и т. д.

Вот несколько примеров:

  1. Функция at у контейнеров std::array, std::vector и std::deque генерирует исключение std::out_of_range при некорректном индексе.

  2. Аналогично, функция at у std::map, std::unordered_map и у соответствующих мультиконтейнеров генерирует исключение std::out_of_range при отсутствующем ключе.

  3. Обращение к значению у пустого объекта std::optional приводит к исключению std::bad_optional_access.

  4. Потоки ввода-вывода могут генерировать исключение std::ios_base::failure.

Исключения в конструкторах

В главе 3.1 мы написали класс Time. Этот класс должен был соблюдать инвариант на значение часов, минут и секунд: они должны были быть корректными. Если на вход конструктору класса Time передавались некорректные значения, мы приводили их к корректным, используя деление с остатком.

Более правильным было бы сгенерировать в конструкторе исключение. Таким образом мы бы явно передали сообщение об ошибке во внешнюю функцию, которая пыталась создать объект.

class Time {
private:
    int hours, minutes, seconds;

public:
    // Заведём класс для исключения и поместим его внутрь класса Time как в пространство имён
    class IncorrectTimeException {
    };

    Time::Time(int h, int m, int s) {
        if (s < 0 || s > 59 || m < 0 || m > 59 || h < 0 || h > 23) {
            throw IncorrectTimeException();
        }
        hours = h;
        minutes = m;
        seconds = s;
    }

    // ...
};

Генерировать исключения в конструкторах — совершенно нормальная практика. Однако не следует допускать, чтобы исключения покидали пределы деструкторов. Чтобы понять причины, посмотрим подробнее, что происходит при генерации исключения.

Свёртка стека

Вспомним класс Logger из предыдущей главы. Посмотрим, как он ведёт себя при возникновении исключения. Воспользуемся в этом примере стандартным базовым классом std::exception, чтобы не писать свой класс исключения.

#include <exception>
#include <iostream>

void f() {
    std::cout << "Welcome to f()!n";
    Logger x;
    // ...
    throw std::exception();  // в какой-то момент происходит исключение
}

int main() {
    try {
        Logger y;
        f();
    } catch (const std::exception&) {
        std::cout << "Something happened...n";
        return 1;
    }
}

Мы увидим такой вывод:

Logger(): 1
Welcome to f()!
Logger(): 2
~Logger(): 2
~Logger(): 1
Something happened...

Сначала создаётся объект y в блоке try. Затем мы входим в функцию f. В ней создаётся объект x. После этого происходит исключение. Мы должны досрочно покинуть функцию. В этот момент начинается свёртка стека (stack unwinding): вызываются деструкторы для всех созданных объектов в самой функции и в блоке try, как если бы они вышли из своей области видимости. Поэтому перед обработчиком исключения мы видим вызов деструктора объекта x, а затем — объекта y.

Аналогично, свёртка стека происходит и при генерации исключения в конструкторе. Напишем класс с полем Logger и сгенерируем нарочно исключение в его конструкторе:

#include <exception>
#include <iostream>

class C {
private:
    Logger x;

public:
    C() {
        std::cout << "C()n";
        Logger y;
        // ...
        throw std::exception();
    }

    ~C() {
        std::cout << "~C()n";
    }
};

int main() {
    try {
        C c;
    } catch (const std::exception&) {
        std::cout << "Something happened...n";
    }
}

Вывод программы:

Logger(): 1  // конструктор поля x
C()
Logger(): 2  // конструктор локальной переменной y
~Logger(): 2  // свёртка стека: деструктор y
~Logger(): 1  // свёртка стека: деструктор поля x
Something happened...

Заметим, что деструктор самого класса C не вызывается, так как объект в конструкторе не был создан.

Механизм свёртки стека гарантирует, что деструкторы для всех созданных автоматических объектов или полей класса в любом случае будут вызваны. Однако он полагается на важное свойство: деструкторы самих классов не должны генерировать исключений. Если исключение в деструкторе произойдёт в момент свёртки стека при обработке другого исключения, то программа аварийно завершится.

Пример с динамической памятью

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

void f() {
    Logger* ptr = new Logger();  // конструируем объект класса Logger в динамической памяти
    // ...
    g();  // вызываем какую-то функцию
    // ...
    delete ptr;  // вызываем деструктор и очищаем динамическую память
}

На первый взгляд кажется, что в этом коде нет ничего опасного: delete вызывается в конце функции. Однако функция g может сгенерировать исключение. Мы не перехватываем его в нашей функции f. Механизм свёртки уберёт со стека лишь сам указатель ptr, который является автоматической переменной примитивного типа. Однако он ничего не сможет сделать с объектом в памяти, на которую ссылается этот указатель. В логе мы увидим только вызов конструктора класса Logger, но не увидим вызова деструктора. Нам придётся обработать исключение вручную:

void f() {
    Logger* ptr = new Logger();
    // ...
    try {
        g();
    } catch (...) {  // ловим любое исключение
        delete ptr;  // вручную удаляем объект
        throw;  // перекидываем объект исключения дальше
    }
    // ...
    delete ptr;

}

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

Согласитесь, этот код очень далёк от совершенства. При непосредственной работе с объектами в динамической памяти нам приходится оборачивать в try/catch любую конструкцию, из которой может вылететь исключение. Понятно, что такой код чреват ошибками. В главе 3.6 мы узнаем, как с точки зрения C++ следует работать с такими ресурсами, как память.

Гарантии безопасности исключений

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

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

template <typename T>
class List {
private:
    struct Node {  // узел двусвязного списка
        T element;
        Node* prev = nullptr;  // предыдущий узел
        Node* next = nullptr;  // следующий узел
    };

    Node* first = nullptr;  // первый узел списка
    Node* last = nullptr;  // последний узел списка
    int elementsCount = 0;

public:
    // ...

    size_t Size() const {
        return elementsCount;
    }

    void PushBack(const T& elem) {
        ++elementsCount;

        // Конструируем в динамической памяти новой узел списка
        Node* node = new Node(elem, last, nullptr);

        // Связываем новый узел с остальными узлами
        if (last != nullptr) {
            last->next = node;
        } else {
            first = node;
        }
        last = node;
    }
};

Не будем здесь рассматривать другие функции класса — конструкторы, деструктор, оператор присваивания… Рассмотрим функцию PushBack. В ней могут произойти такие исключения:

  1. Выражение new может сгенерировать исключение std::bad_alloc из-за нехватки памяти.

  2. Конструктор копирования класса T может сгенерировать произвольное исключение. Этот конструктор вызывается при инициализации поля element создаваемого узла в конструкторе класса Node. В этом случае new ведёт себя как транзакция: выделенная перед этим динамическая память корректно вернётся системе.

Эти исключения не перехватываются в функции PushBack. Их может перехватить код, из которого PushBack вызывался:

#include <iostream>

class C;  // какой-то класс

int main() {
    List<C> data;
    C element;

    try {
        data.PushBack(element);
    } catch (...) {  // не получилось добавить элемент
        std::cout << data.Size() << "n";  // внезапно 1, а не 0
    }

    // работаем дальше с data
}

Наша функция PushBack сначала увеличивает счётчик элементов, а затем выполняет опасные операции. Если происходит исключение, то в классе List нарушается инвариант: значение счётчика elementsCount перестаёт соответствовать реальности. Можно сказать, что функция PushBack не даёт гарантий безопасности.

Всего выделяют четыре уровня гарантий безопасности исключений (exception safety guarantees):

  1. Гарантия отсутствия сбоев. Функции с такими гарантиями вообще не выбрасывают исключений. Примерами могут служить правильно написанные деструктор и конструктор перемещения, а также константные функции вида Size.

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

  3. Базовая гарантия безопасности. При исключении состояние объекта может поменяться, но оно останется внутренне согласованным, то есть, инварианты будут соблюдаться.

  4. Отсутсвие гарантий. Это довольно опасная категория: при возникновении исключений могут нарушаться инварианты.

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

Переместим в нашей функции PushBack изменение счётчика в конец:

    void PushBack(const T& elem) {
        Node* node = new Node(elem, last, nullptr);

        if (last != nullptr) {
            last->next = node;
        } else {
            first = node;
        }
        last = node;

        ++elementsCount;  // выполнится только если раньше не было исключений
    }

Теперь такая функция соответствует строгой гарантии безопасности.

В документации функций из классов стандартной библиотеки обычно указано, какой уровень гарантии они обеспечивают. Рассмотрим, например, гарантии безопасности класса std::vector.

  • Деструктор, функции empty, size, capacity, а также clear предоставляют гарантию отсутствия сбоев.

  • Функции push_back и resize предоставляют строгую гарантию.

  • Функция insert предоставляет лишь базовую гарантию. Можно было бы сделать так, чтобы она предоставляла строгую гарантию, но за это пришлось бы заплатить её эффективностью: при вставке в середину вектора пришлось бы делать реаллокацию.

Функции класса, которые гарантируют отсутсвие сбоев, следует помечать ключевым словом noexcept:

class C {
public:
    void f() noexcept {
        // ...
    }
};

С одной стороны, эта подсказка позволяет компилятору генерировать более эффективный код. С другой — эффективно обрабатывать объекты таких классов в стандартных контейнерах. Например, std::vector<C> при реаллокации будет использовать конструктор перемещения класса C, если он помечен как noexcept. В противном случае будет использован конструктор копирования, который может быть менее эффективен, но зато позволит обеспечить строгую гарантию безопасности при реаллокации.

I found a new way to do it. It is non-standard and this is an entirely new way to do it.
So consider using this approach cautiously.

Use the following header file:

SetError.h:

#include <string> // for string class 



#ifndef SET_ERROR_IS_DEFINED
#define SET_ERROR_IS_DEFINED

class Error {

public:
    int code = 0;
    std::string errorMessage;
    std::string fileName;
    std::string functionName;

    Error() {}

    Error(int _errorCode, std::string _functionName = "", std::string _errorMessage = "", std::string _fileName = "")
    {
        code = _errorCode;
        functionName = _functionName;
        errorMessage = _errorMessage;
        fileName = _fileName;
    }
};

#if defined(_DEBUG) || !defined(NDEBUG) 
#define ___try { _ERROR.code = 0; bool __valid_try_mode_declared; 
#define ___success }
#define SetError(pErrorData) __valid_try_mode_declared = true; _ERROR = *pErrorData; delete pErrorData;
#else
#define ___try { _ERROR.code = 0;
#define ___success }
#define SetError(pErrorData) _ERROR = *pErrorData; delete pErrorData; 
#endif

#endif

inline Error _ERROR;

Include it everyware.

Example of how to use:

Main.cpp:

#include "SetError.h"
#include <iostream>


bool SomeFunction(int value) ___try; 
{ 


    if (value < 0) {
        SetError(new Error(10, "SomeFunction", "Some error", "File main.cpp"));
        return false;
    }

    return true;
} ___success; // You mast to warp the function with both ___try and ___success
// These keywords must be at the start and the end of the function!




int main()
{
    using namespace std;

    bool output = SomeFunction(-1);

    if (_ERROR.code != 0) { // This is how you check the error code. using the global _ERROR object
        cout << "error code: " << _ERROR.code << ", from function: " 
            << _ERROR.functionName << ", from file: " << _ERROR.fileName;
    }

    cout << endl << "Founction returned: " << output << endl;

    return 1;
}

If you have some functions that run in another thread, these functions need to be inside namespace and then you can do this:

namespace FunctionsInSomeThread
{
    #include "SetError.h"

    bool SomeFunc1() ___try;
    {
        SetError(new Error(5, "SomeFunction2", "Some error from another thread", "File main.cpp"))
        return true;
    } ___success;

    bool SomeFunc2() ___try;
    {
        SetError(new Error(5, "SomeFunction2", "Some error from another thread", "File main.cpp"))
            return true;
    } ___success;

}

And to access _Error, you need to add the namespace of the thread

if (FunctionsInSomeThread::_ERROR.code != 0)
{
    // Error handling
}

Or in case it is inside the same namespace then no need to add FunctionsInSomeThread:: before.

The idea behind this is that you can’t warp the function only with ___success; keyword. You will get compile error. So the developer will never return old error code from another function.

If you wrote ___success; at the end of the codeblock, you must write also ___try; at the start!
You also can’t use SetError macro if it is not wrapped in ___try; and ___success;.

The idea come from the AutoIt language where you have this consept:
https://www.autoitscript.com/autoit3/docs/functions/SetError.htm

So this is almost the same in C if you use this header.

Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Коды ошибок

JS: Обработка ошибок

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

Например, если мы возвращаем ошибку файловой системы, то обычно хотим знать, что за ошибка произошла. Например, в файловых системах в UNIXO количество ошибок превышает 100:

#define EPERM    1  /* Operation not permitted */
#define ENOENT   2  /* No such file or directory */
#define ESRCH    3  /* No such process */
#define EINTR    4  /* Interrupted system call */
#define EIO      5  /* I/O error */
#define ENXIO    6  /* No such device or address */
#define E2BIG    7  /* Arg list too long */
#define ENOEXEC  8  /* Exec format error */
#define EBADF    9  /* Bad file number */
#define ECHILD  10  /* No child processes */

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

В этом случае появляется вопрос, как действовать — как возвращать результат. Один из самых простых вариантов — это коды возврата.

Коды возврата

Возврат ошибки связан с тем, что мы возвращаем не просто false или undefined. Мы возвращаем некоторое число. Оно говорит о том, какая ошибка произошла. Это работает так:

// Вызываем команду, например, удаление директории, и получаем результат
const result = files.rmdir(path);

// Проверяем, является ли результат частью списка ошибок. Перечисляем возможные варианты
if ([/* errors list */].includes(result)) {
  // error
} else {
  // success
}

В итоге мы узнаем, входит ли результат в список ошибок. Если всё хорошо, то мы продолжаем работать и используем тот же результат.

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

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

Возврат результата в СИ

В Си принят следующий подход возврата результата:

#include <stdio.h>
#include <errno.h>

extern int errno;

int main () {
  FILE * fp;
  # Открываем файл на чтение
  fp = fopen("filedoesnotexist.txt", "rb");
  # Делаем проверку
  if (fp == NULL) { // Value of errno is: 2
    # Если указатель равен нулю, то пишем, что произошла ошибка
    fprintf(stderr, "Value of errno: %dn", errno);
  } else {
    fclose (fp);
  }
  return 0;
}

Ошибка записывается в глобальную переменную, которая называется errno. И там, например, будет цифра 2, которая означает, что всё плохо.

Здесь мы убеждаемся, что у нас нет адекватного результата. Но по сути идет глобальное изменение среды. А глобальные переменные — это не вариант, особенно, в современных языках.

Возврат результата в Golang

Рассмотрим более продвинутый способ. Этот тот же способ, что и в СИ, но улучшенный. Он называется Golang Style.

Golang — это современный язык, который избавляет от глобальных переменных.

Смотрим пример:

package main
import (
  "fmt"
  "io"
  "io/ioutil"
)

func main() {
  // Возращается два значения: dat — данные, err — ошибка
  dat, err := ioutil.ReadFile("/tmp/dat")
  // Проверяем, равна ли ошибка nil
  if err != nil {
    fmt.Println(err.Error())
  }
  fmt.Print(string(dat))
}

Если ошибка не равна nil, то ошибки нет, и всё хорошо. Если равна nil, то мы обрабатываем ее.

Это фактически такой же подход как в CИ. Только здесь нет глобальной переменной, которая постоянно меняется. Каждая функция возвращает свой результат выполнения. Так мы двигаемся вниз по вызовам и можем строить программу.

Возврат результата в JavaScript

В JavaScript можно использовать такой же подход. Это рабочая схема, которая основана на Destructuring. У нее есть определенные недостатки, которые мы разберем в следующем уроке.

Рассмотрим пример:

// Делаем чтение, описываем данные и ошибку
const [data, err] = files.readFileSync('/unknown');
if (err === null) {
  // do something with data
} else {
  // handle error
}

// return [null, errors.code.ENOENT];

Если ошибка равна null, то делаем все что хотим, если нет, то обрабатываем ошибку.

Понравилась статья? Поделить с друзьями:
  • Return to castle wolfenstein ошибка при установке
  • Return to castle wolfenstein ошибка opengl как исправить
  • Return to castle wolfenstein hunkusage dat ошибка
  • Return outside method java ошибка
  • Return of reckoning warhammer online ошибка