Пара слов от переводчика


Продолжая освещать тему std::system_error в рунете, я решил перевести несколько статей из блога Andrzej Krzemienski, который мне посоветовали в комментариях к предыдущему посту.

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

Так же хочу предупредить, что у Andrzej Krzemienski достаточно сумбурный стиль изложения, который я не стал править. Все же я выступаю в роли переводчика, а не редактора. Так что, возможно, чтобы понять некоторые тезисы придется перечитать дважды.

Введение


Недавно в моем приложении я реализовывал «классификацию условий ошибок» предлагаемую функционалом std::error_code. В этом посте я хочу поделиться своим опытом.

C++11 имеет довольно сложный механизм классификации условий ошибок. Вы могли сталкиваться с такими понятиями как «код ошибки», «условие ошибки», «категория ошибки», но выяснить насколько они хороши и как их использовать непросто. Единственным ценным источником информации по этому вопросу в интернете является цикл статей от Christopher Kohlhoff, автора библиотеки Boost.Asio:


[Примечание переводчика: ранее я уже перевел этот цикл для хабра]

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

Проблема


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

  • для поиска (короткой) последовательности рейсов, которые доставят вас до места назначения
  • для проверки наличия на этих рейсах мест в запрашиваемом классе обслуживания (эконом-класс, бизнес-класс)

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

enum class FlightsErrc
{
    // не 0
    NonexistentLocations = 10, // запрашиваемого аэропорта не существует
    DatesInThePast,            // бронирование рейса на прошедшую дату
    InvertedDates,             // отбытие до прибытия
    NoFlightsFound       = 20, // не найдено ни одной комбинации
    ProtocolViolation    = 30, // например, невалидный XML
    ConnectionError,           // не удалось подключиться к серверу
    ResourceError,             // службе не хватает ресурсов
    Timeout,                   // ответ не получен вовремя
};

enum class SeatsErrc
{
    // не 0
    InvalidRequest = 1,        // например, невалидный XML
    CouldNotConnect,           // не удалось подключиться к серверу
    InternalError,             // службе не хватает ресурсов
    NoResponse,                // ответ не получен вовремя
    NonexistentClass,          // запрашиваемый класс не существует
    NoSeatAvailable,           // все места заняты
};

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

Во-вторых, как видно из названий, причины отказов имеют разные источники:

  • Окружение: внутренняя проблема сервиса (например, с ресурсами).
  • Недопонимание: между двумя сервисами.
  • Пользователь: предоставление неверных данных в запросе.
  • Просто невезение: на самом деле не ошибка, но ответ не может быть возвращен пользователю, потому что, например, все места были распроданы.

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

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

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

std::error_code


Стандартная библиотека std::error_code предназначена для хранения именно такого типа информации: числа, представляющего статус, и «домена», в пределах которого этому числу присваивается значение. Другими словами, std::error_code представляет собой пару: {int, domain}. Это отражено в его интерфейсе:

void inspect(std::error_code ec)
{
    ec.value();    // число
    ec.category(); // домен
}

Но вам почти никогда не нужно проверять std::error_code таким образом. Как мы уже говорили, две вещи, которые мы хотим сделать — это залогировать состояние std::error_code каким оно было создано (без последующего преобразования более высокими уровнями приложения) и использовать его для ответа на конкретный вопрос, например: «Эта ошибка вызвана пользователем, предоставившим неверные данные?».

Если вы спрашиваете себя, зачем использовать std::error_code вместо исключений, позвольте мне пояснить: эти две вещи не являются взаимоисключающими. Я хочу сообщать о сбоях в моей программе через исключения. Внутри исключения, вместо строк, я хочу хранить std::error_code, который я могу легко проверить.
std::error_code не имеет никакого отношения к избавлению от исключений. Кроме того, в моем случае использования, я не вижу веской причиной иметь много разных типов исключений. Я буду ловить их всего в одном (или двух) местах, а узнавать о разных ситуациях я буду проверяя объект std::error_code.

Подключение вашего перечисления


Теперь мы хотим адаптировать std::error_code, чтобы он мог хранить ошибки из сервиса рейсов, описанного выше:

enum class FlightsErrc
{
    // не 0
    NonexistentLocations = 10, // запрашиваемого аэропорта не существует
    DatesInThePast,            // бронирование рейса на прошедшую дату
    InvertedDates,             // отбытие до прибытия
    NoFlightsFound       = 20, // не найдено ни одной комбинации
    ProtocolViolation    = 30, // например, невалидный XML
    ConnectionError,           // не удалось подключиться к серверу
    ResourceError,             // службе не хватает ресурсов
    Timeout,                   // ответ не получен вовремя
};

Мы должны иметь возможность конвертировать [значения] из нашего enum в std::error_code:

std::error_code ec = FlightsErrc::NonexistentLocations;

Но наше перечисление должно соответствовать одному условию: числовое значение 0 не должно представлять собой ошибочную ситуацию. 0 представляет успех в любом домене (категории) ошибок. Этот факт позже будет использован при проверке объекта std::error_code:

void inspect(std::error_code ec)
{
    if(ec) // эквивалентно: 0 != ec.value()
        handle_failure(ec);
    else
        handle_success();
}

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

Так вот что мы сделали: мы не начали перечисление FlightErrc с 0. Это, в свою очередь, означает, что мы можем создать перечисление, которое не соответствует ни одному из перечисленных значений:

FlightsErrc fe {};

Это важная характеристика перечислений в C++ (даже классов перечислений из C++11): вы можете создавать значения вне диапазона перечисления. Именно по этой причине компиляторы выдают предупреждение в switch-statement, что «не все пути управления возвращают значение», даже если у вас есть метка case для каждого перечисляемого значения.

Теперь вернемся к преобразованию, std::error_code имеет шаблон конструктора преобразования, который выглядит более-менее следующим образом:

template<class Errc>
requires is_error_code<Errc>::value
error_code(Errc e) noexcept:
    error_code{make_error_code(e)}
{}

(Конечно, я использовал еще несуществующий концептуальный синтаксис, но идея должна быть понятна: этот конструктор доступен только тогда, когда std::is_error_code<Errc>::value оценивается как true.)

Этот конструктор предназначен для настройки подключаемых пользовательских перечислений в систему. Чтобы подключить FlightErrc, мы должны убедиться, что:

  1. std::is_error_code<Errc>::value возвращает true.
  2. Функция make_error_code принимающая тип FlightsErrc определена и доступна через ADL.

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

namespace std
{
    template<>
    struct is_error_code_enum<FlightsErrc>:
        true_type
    {};
}

Это одна из тех ситуаций, когда объявление чего-либо в пространстве имен std является «законным».

Что касается второго пункта, нам просто нужно объявить перегрузку функции make_error_code в том же пространстве имен, что и FlightsErrc:

enum class FlightsErrc;
std::error_code make_error_code(FlightsErrc);

И это все, что нужно видеть другим частям программы/библиотеки и что мы должны предоставить в заголовочном файле. Остальное — реализация функции make_error_code, и мы можем поместить ее в отдельную единицу трансляции (файл .cpp).

С этого места мы можем считать, что FlightsErrc это error_code:

std::error_code ec = FlightsErrc::NoFlightsFound;
assert(ec == FlightsErrc::NoFlightsFound);
assert(ec != FlightsErrc::InvertedDates);

Объявление категории ошибок


До этого момента я говорил, что error_code — это пара: {number, domain}, где первый элемент однозначно идентифицирует конкретную ошибочную ситуацию в домене, а второй однозначно идентифицирует домен ошибок среди всех возможных доменов ошибок, которые когда-либо будут задуманы. Но, учитывая, что этот идентификатор домена должен храниться в одном машинном слове, как мы можем гарантировать, что он будет уникальным для всех библиотек, находящихся в настоящее время на рынке, и тех, которые еще впереди? Мы скрываем идентификатор домена как деталь реализации. Если мы хотим использовать другую стороннюю библиотеку со своим собственным перечислением ошибок, как мы можем гарантировать, что их идентификатор домена не будет равен нашему?

Решение, выбранное для std::error_code, основывается на наблюдении, что для каждого глобального объекта (или более формально: объекта области пространства имен) присваивается уникальный адрес. Независимо от того, сколько библиотек и с каким количеством глобальных объектов объединено вместе, каждый глобальный объект имеет уникальный адрес — это совершенно очевидно.

Чтобы воспользоваться этим, мы должны связать с каждым типом, который хотим подключить к системе error_code, уникальный глобальный объект, а затем использовать его адрес как идентификатор. Теперь для представления домена будут использоваться адреса, это как раз то, что и делает std::error_code. Но теперь, когда мы храним некоторый Т*, возникает вопрос: чем должен быть Т? Довольно рациональный выбор: давайте использовать тот тип, который может предложить нам дополнительные преимущества. Итак, используемый тип Tstd::error_category, а дополнительное преимущество — в его интерфейсе:

class error_category
{
public:
    virtual const char * name() const noexcept = 0;
    virtual string message(int ev) const = 0;
    // другие члены класса ...
};

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

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

В от std::error_category есть и другие виртуальные методы, которые в других случаях нужно настраивать, но для подключения FlightErrc нам этого делать не придется.

Теперь, для каждого пользовательского «домена» ошибок, представленного классом, производным от от std::error_category, мы должны переопределить два метода. Метод name возвращает короткое мнемоническое имя категории (домена) ошибок. Метод message назначает текстовое описание для каждого числового значения ошибки в этом домене. Чтобы лучше проиллюстрировать это, давайте определим категорию ошибок для нашего перечисления FlightsErrc. Помните, что этот класс должен быть видимым только в одной единице трансляции. В других файлах мы просто будем использовать адрес его экземпляра.

namespace
{

    struct FlightsErrCategory:
        std::error_category
    {
        const char * name() const noexcept override;
        std::string message(int ev) const override;
    };

    const char * FlightsErrCategory::name() const noexcept
    {
        return "flights";
    }

    std::string FlightsErrCategory::message(int ev) const
    {
        switch(static_cast<FlightsErrc>(ev))
        {
            case FlightsErrc::NonexistentLocations:
                return "nonexistent airport name in request";

            case FlightsErrc::DatesInThePast:
                return "request for a date from the past";

            case FlightsErrc::InvertedDates:
                return "requested flight return date before departure date";

            case FlightsErrc::NoFlightsFound:
                return "no filight combination found";

            case FlightsErrc::ProtocolViolation:
                return "received malformed request";

            case FlightsErrc::ConnectionError:
                return "could not connect to server";

            case FlightsErrc::ResourceError:
                return "insufficient resources";

            case FlightsErrc::Timeout:
                return "processing timed out";

            default:
                return "(unrecognized error)";
        }
    }
 
    const FlightsErrCategory theFlightsErrCategory {};

}

Метод name предоставляет короткий текст, который используется при потоковой передаче std::error_code в такие вещи, как, например, журналы: это может помочь вам определить причину ошибки. Текст не обязан быть уникальным во всех перечислениях ошибок: в худшем случае записи в журнале будут неоднозначными.

Метод message предоставляет текст-описание для любого числового значения, представляющего собой ошибку в нашей категории. Это может быть полезно при отладке или просмотре журналов; но вы, вероятно, не захотите показывать этот текст пользователям без дополнительной обработки.

Обычно этот метод вызывают не напрямую. Вызывающие не могут знать, что числовое значение это FlightErrc, поэтому мы должны явно привести его обратно к FlightErrc. Я считаю, что пример в вышеупомянутой статье не скомпилируется из-за пропущенного static_cast. После приведения типов существует риск того, что мы будем проверять значение, которое не относится к перечислению: поэтому нам нужна default метка.

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

Хотя класс std::error_category не является литеральным типом, он имеет constexpr конструктор по умолчанию. Неявно объявленный конструктор по умолчанию нашего класса FlightErrCategory наследует это свойство. Таким образом, мы гарантируем, что инициализация нашего глобального объекта выполняется во время инициализации констант, как описано в этой статье, и поэтому не содержит никаких проблем с порядком статической инициализации.

Теперь последняя недостающая часть — реализация make_error_code:

std::error_code make_error_code(FlightsErrc e)
{
    return {static_cast<int>(e), theFlightsErrCategory};
}

И мы закончили. Наш FlightsErrc может быть использован так, как если бы это был std::error_code:

int main()
{
    std::error_code ec = FlightsErrc::NoFlightsFound;
    std::cout << ec << std::endl;
}

Вывод этой программы будет таким:

flights:20

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

И это все на сегодня. Мы еще не рассмотрели как делать полезные запросы по объектам std::error_code, но это будет темой для другой статьи.

Благодарности


Я благодарен Tomasz Kaminski за объяснение мне идеи, стоящей за std::error_code. Помимо цикла статей от Christopher Kohlhoff, я также смог узнать о std::error_code из документации к Outcome library от Niall Douglas, здесь.

Комментарии (1)


  1. TargetSan
    20.10.2017 22:55

    Disclaimer: претензия ниже не к автору и не к переводчику.
    Как по мне, этот механизм как был недоделан, так и остался. Попытка навести марафет на трупик errno.