Типичные сценарии распространения и обработки исключений. Часть 2.
Из ленты: OpenQuality.ru | Качество программного обеспечения | Опыт экспертов
Продолжение. Первая часть серии статей опубликована здесь.
Класс UserPreferences
Класс CFile из первый части статьи продемонстрировал нам, каким образом API, на котором базируется класс, определяет политику распространения исключений данного класса. Там же говорилось о “функциональной совместимости”, которую приходится соблюдать при создании новых классов, использующих некоторый “базовый код”. В первой части в качестве примера рассматривалось создание новой библиотеки. Во второй части полученный опыт будет экстраполирован на создание пользовательского класса.
Для демонстрации, я привлеку на помощь стандартную библиотеку C++ [C++ standard library] (ISO/IEC 14882:2011 раздел 17). В чём очарование стандартной библиотеки? Она присутствует во всех реализациях, а значит универсальна, кросс-платформенна и знакома многим разработчикам.
Из всего множества классов этой библиотеки сейчас нас будут интересовать лишь потоки ввода/вывода [input/output library] (ISO/IEC 14882:2011 раздел 27). Мы рассмотрим, как можно задействовать эти потоки для сохранения/восстановления состояния объектов и обсудим тактику распространения ошибок.
Для наглядности представим, что мы работаем над текстовым редактором. Наш редактор, помимо обязательных для него функций, даёт возможность пользователю сохранять различные настройки – размер и стиль шрифтов, путь для сохранения файлов, параметры авто-замены и т.п. Теперь предположим, что за работу с этими данными отвечает класс UserPreferences, который мог бы выглядеть следующим образом:
class UserPreferences { public: UserPreferences(int intValue, const std::string& stringValue, float floatValue); private: int mIntValue; std::string mStringValue; float mFloatValue; };
Мы с вами хотим, чтобы настройки, выбранные пользователем, не теряли свою силу после того, как приложение было закрыто, а затем открыто повторно. И для этого нам понадобится сохранять состояние класса UserPreferences. Как было сказано выше, мы будем делать это с помощью потоков ввода/вывода. Добавим следующие объявления двух глобальных функций:
std::ostream& operator<<(std::ostream& out, const UserPreferences& userPreferences); std::istream& operator>> (std::istream& in, UserPreferences& userPreferences);
Это позволит нам в дальнейшем писать код сохранения и восстановления состояния объекта на манер стандартной библиотеки C++.
Так может выглядеть сохранение:
void MainApplication::SaveUserPreferences(const UserPreferences& userPreferences) { std::ostringstream out; out << userPreferences; saveToDisk(out); }
А так – восстановление:
UserPreferences MainApplication::RestoreUserPreferences() { std::string str = readFromDisk(); std::istringstream in(str); UserPreferences userPreferences; in >> userPreferences; return userPreferences; }
В свою очередь, реализации методов operator<< и operator>> будут обращаться к публичным методам класса UserPreferences:
std::ostream& operator<<(std::ostream& out, const UserPreferences& userPreferences) { userPreferences.Serialize(out); return out; } std::istream& operator>>(std::istream& in, UserPreferences& userPreferences) { userPreferences = UserPreferences::UnSerialize(in); return in; }
Таким образом, вся реальная работа по сохранению объектов будет производиться в методах Serialize и UnSerialize.
Помимо методов Serialize и UnSerialize нам понадобится добавить конструктор по умолчанию [default constructor] – он используется в методе MainApplication::RestoreUserPreferences. Описание класса UserPreferences будет выглядеть следующим образом:
class UserPreferences { public: UserPreferences(); UserPreferences(int intValue, const std::string& stringValue, float floatValue); void Serialize(std::ostream& out) const; static UserPreferences UnSerialize(std::istream& in); private: int mIntValue; std::string mStringValue; float mFloatValue; }; std::ostream& operator<<(std::ostream& out, const UserPreferences& userPreferences); std::istream& operator>>(std::istream& in, UserPreferences& userPreferences);
К описанию класса также необходимо отнести и методы operator<< и operator>>, которые, хотя и описаны вне тела класса, тем не менее являются частью его интерфейса.
Сохранение состояния объекта – это большая тема, заслуживающая отдельного рассмотрения, но мы с вами не будем на ней останавливаться. Я скажу лишь пару слов о ключевых моментах, прежде чем перейду к вопросу обработки исключительных ситуаций.
Тем, кто заинтересовался вопросом сохранения состояния объектов, я бы порекомендовал краткий Serialization and Unserialization FAQ от Marshall Cline – http://www.parashift.com/c++-faq/serialization.html#faq-36.3. Данный FAQ является частью сборника ответов на часто задаваемые вопросы группы новостей http://groups.google.com/group/comp.lang.c++.
Для сохранения/чтения состояния объектов с помощью потоков у нас есть возможность выбрать формат сохранения (текстовый или бинарный). У каждого из них есть свои плюсы и минусы. Мы остановимся на текстовом, как более наглядном. При использовании текстового формата нам необходимо выбрать подходящие разделители. Мы возьмём пробел в качестве разделителя для чисел. Это позволит нам при считывании числа полагаться на вызов basic_istream::operator>>, для которого пробел послужит “границей”.
С разделителем для строк чуть сложнее: мы не хотим ограничивать себя сохранением лишь тех строк, в которых не содержатся пробелы. Потому выберем в качестве разделителя нулевой символ (‘\0’) и создадим свой метод – метод Read – для считывания строк.
UserPreferences UserPreferences::UnSerialize(std::istream& in) { int intValue; in >> intValue; in.ignore(); // #1 - символ разделителя всё ещё в потоке - пропускаем его std::string stringValue; Read(in, stringValue); float floatValue; in >> floatValue; in.ignore(); // #3 - позволяем сохранять в поток другие объекты return UserPreferences(intValue, stringValue, floatValue); } void UserPreferences::Serialize(std::ostream& out) const { out << mIntValue << ' ' << mStringValue << '\0' << mFloatValue << ' '; // #2 - позволяем сохранять в поток другие объекты } void UserPreferences::Read(std::istream& in, std::string& out) { char c; while(in.get(c)) { if(c==0) { break; } out.push_back(c); } }
В этой нехитрой реализации следует помнить о двух моментах.
Первый касается метода basic_istream::operator>>. Этот метод, считав из потока число, оставит в потоке символ-разделитель, прервавший считывание (в нашем случае это пробел). Поэтому нам необходимо пропустить символ-разделитель перед считыванием следующего члена класса (строка #1 в функции UserPreferences::UnSerialize).
Второй момент относится к записи разделителя (опять же, пробела) после записи последнего члена класса – mFloatValue (строка #2 в функции UserPreferences::Serialize). А также к пропуску этого же разделителя при восстановлении состояния объекта (строка #3 в функции UserPreferences::UnSerialize). Мы добавляем данный код для того, чтобы разрешить сохранение в тот же поток других объектов.
Другими словами, класс UserPreferences оставляет потоки ввода и вывода в состоянии, в котором они готовы к повторному использованию. Это позволяет писать следующий код:
void MainApplication::Save(const UserPreferences& userPreferences, const ApplicationData& applicationData) { std::ostringstream out; out << userPreferences << applicationData; saveToDisk(out); } void MainApplication::Restore() { std::string str = readFromDisk(); std::istringstream in(str); UserPreferences userPreferences; ApplicationData applicationData; in >> userPreferences >> applicationData; // Восстанавливаем состояние приложения с помощью // userPreferences и applicationData }
В скобках замечу, что возможность сохранения нескольких объектов в единственный поток имеет как плюсы, так и минусы. Наряду с удобством записи мы получаем риск потери состояния всех объектов при возникновении ошибки в момент сохранения/восстановления, тогда как при использовании связки “один поток – один объект” мы рискуем только одним объектом.
Возможно, удачным компромиссом будет сохранения в один поток лишь вложенных объектов. Например, будучи составным объектом, класс UserPreferences мог бы сохранять/восстанавливать своё состояние, делегируя операции частям, из которых состоит:
void UserPreferences::Serialize(std::ostream& out) const { out << mSubstitution << << mFonts << << mEnvironment; } UserPreferences UserPreferences::UnSerialize(std::istream& in) { UserPeferences userPreferences; in >> userPreferences.mSubstitution >> userPreferences.mFonts >> userPreferences.mEnvironment; return userPreferences; }
Здесь мы полагаем, что mSubstitution, mFonts и mEnvironment уже не примитивные типы, а дисциплинированные объекты, умеющие сохранять себя сами. Любопытно отметить, что работая с объектами, мы поднимаемся на чуть больший уровень абстракции, и избавляемся от необходимости думать о разделителях.
Возможно, для многих это покажется очевидным, но необходимо отметить, что данную реализацию следует рассматривать лишь как обучающий пример, который не претендует на повторное использование. В реальных проектах от неё следует отказаться в пользу хорошо известных/зарекомендовавших себя сторонних библиотек. Библиотеки сохранения/восстановления состояния объекта избавляют разработчика от забот о версионности, поддержке различных локализаций а также порядке байт при работе с сетевыми протоколами.
Единственным преимуществом данной реализации является её простота, которая даёт некоторую уверенность, что данный код будет правильно работать в большинстве случаев. Но эта простота не перевешивает потенциальных проблем, с которыми может столкнуться конечный пользователь.
Итак, мы закончили с вступлением и теперь можем перейти к вопросу распространения ошибок. Рассмотрим каждую из функций нашего класса. И начнём мы с конструкторов.
UserPreferences::UserPreferences
Мы ещё не приводили тело конструктора по умолчанию для класса UserPreferences. Давайте это сделаем сейчас:
UserPreferences::UserPreferences() { }
Оставив тело конструктора пустым, мы позволили компилятору вызвать конструкторы по умолчанию для членов класса mIntValue, mFloatValue и mStringValue. Как следствие, переменные тривиальных типов (mIntValue и mFloatValue) будут инициализированы случайными значениями, а для mStringValue будет вызван конструктор:
explicit basic_string(const Allocator& a = Allocator());
Последний и объясняет, почему нам приходится объявлять UserPreferences::UserPreferences без использования пустой спецификации noexcept [noexcept-specification].
Напомню, что последняя версия стандарта объявляет динамическую спецификацию исключений [dynamic-exception-specification] устаревшей. Взамен неё предлагается спецификация noexcept [noexcept-specification] (ISO/IEC 14882:2011, раздел 15.4). Таким образом, чтобы в новом стандарте объявить функцию, не испускающую исключений, необходимо добавить к ней суффикс noexcept взамен (привычного) throw():
void method_does_not_throw_exception() noexcept;
Согласно стандарту, конструктор basic_string(const Allocator&) может инициировать любое исключение.
Интересно провести подробный анализ метода basic_string(const Allocator&) с точки зрения возможной генерации исключения.
Шаблонный класс Allocator в данном случае – это std::allocator, который является распределителем памяти по умолчанию для стандартной библиотеки (ISO/IEC 14882:2011 раздел 20.6.9). И для которого конструктор копирования объявлен как метод, который не генерирует исключений:
allocator(const allocator&) noexcept;
Т.е. если исключение и может быть инициировано, то только из тела метода basic_string(const Allocator&).
Продолжим анализ и рассмотрим постусловия, накладываемые стандартом на данную функцию – basic_string(const Allocator&) (ISO/IEC 14882:2011 раздел 21.4.2):
Элемент | Значение |
data() | Ненулевой указатель, который может быть скопирован и к которому может быть прибавлен 0 [a non-null pointer that is copyable and can have 0 added to it] |
size() | 0 |
capacity() | Неопределённое значение [an unspecified value] |
Здесь нас будут интересовать методы size() и capacity(). Требование к методу size() говорит о том, что basic_string(const Allocator&) может не заниматься выделением памяти (и, как следствие, может не генерировать bad_alloc). Требование же к capacity() говорят об обратном – неопределённое значение, возвращаемое этим методом может быть любым, в том числе и не нулевым. Т.е. рассматриваемый конструктор вправе выделить память, что в свою очередь может привести к генерации bad_alloc.
Соберём всё вместе. Ни конструкторы тривиальных типов, ни конструктор копирования std::allocator не приводят к возникновения исключения. Исключение может произойти лишь в конструкторе по умолчанию basic_string (здесь “может” нужно понимать как – “стандарт разрешает”). Вероятно, это исключение будет иметь тип bad_alloc. Больше никаких утверждений сделать нельзя. Зато с большой долей уверенности можно сказать, что в системе должно произойти нечто серьёзное, чтобы конструктор строки по умолчанию инициировал исключение.
С одной стороны результат анализа может показаться неожиданным: безобидный, на первый взгляд, пустой конструктор может генерировать std::bad_alloc, а возможно и исключение неизвестного типа. Но давайте посмотрим на это с другой стороны.
Во-первых, стандарт утверждает, что все исключения, инициированные стандартной библиотекой, должны быть производными от класса std::exception (ISO/IEC 14882:2011 раздел 18.8.1). То есть, класс исключения известен, и при желании мы всегда можем его перехватить.
Во-вторых, что более важно, данный конструктор – это не единственное место в нашем приложении, которое может генерировать “неудобное” исключение. Всё, что от нас потребуется – это корректно перехватить и корректно обработать подобную ошибку.
Вопросу перехвата будет посвящён отдельный раздел, сейчас же мы вернёмся к текущей задаче, а именно – к определению типов исключений, которые могут и/или должны генерировать методы класса UserPreferences.
Итак, мы обнаружили, что конструктор класса по умолчанию, UserPreferences(), может инициировать исключение, принадлежащее классу std::exception либо классу, который является наследником std::exception. Отметим это для себя и перейдём к следующему методу.
UserPreferences::UserPreferences(int intValue, const std::string& stringValue, float floatValue);
UserPreferences::UserPreferences(int intValue, const std::string& stringValue, float floatValue) : mIntValue(intValue), mStringValue(stringValue), mFloatValue(floatValue) { }
Ничего принципиально нового в этом методе, по сравнению с конструктором по умолчанию, мы не видим. В данном случаем при создании члена класса mStringValue будет вызван метод basic_string(const basic_string& str), который как и basic_string(const Allocator&) допускает генерацию исключений. Поэтому, делаем вывод, что данный метод также инициирует исключение std::exception.
Любопытно отметить, что у нас есть возможность объявить ещё один конструктор, который будет очень похож на данный конструктор, но который не будет генерировать исключений. Для этого нужно воспользоваться нововведением последней версии стандарта C++, а именно конструктором переноса [move constructor], слегка изменив прототип нашей функции:
UserPreferences::UserPreferences(int intValue, std::string&& stringValue, float floatValue) noexcept;
Дело в том, что конструктор переноса для класса basic_string объявлен, как негенерирующий исключения (что логично, поскольку происходит лишь копирование данных без выделения памяти):
basic_string(basic_string&& str) noexcept;
Это позволяет, задействовав его, объявить конструктор UserPreferences также негенерирующим исключения.
void UserPreferences::Serialize(std::ostream& out) const
Наконец мы можем перейти от конструкторов с пустым телом к рассмотрению методов, которые действительно что-то делают. Начнём с метода Serialize – приведём тело метода ещё раз:
void UserPreferences::Serialize(std::ostream& out) const { out << mIntValue << ' ' << mStringValue << '\0' << mFloatValue << ' '; }
Реализация данного метода использует три различных метода operator<< для сохранения состояния класса:
basic_ostream& basic_ostream::operator<<(int val); basic_ostream& basic_ostream::operator<<(float val); basic_ostream& operator<<(basic_ostream& os, const basic_string& str);
Два первых метода являются членами класса basic_ostream, а последний объявлен в пространстве имён std. Нас будет интересовать вопрос: могут ли данные функции генерировать исключения. И если могут, то какому типу будут принадлежать эти исключения.
Первым мы рассмотрим basic_ostream::operator<<(int val). Данный оператор относится к функциям форматированного вывода [formatted output functions], требования к которым описаны в разделе 27.7.3.6 стандарта ISO/IEC 14882:2011. Стандарт достаточно строг в описании, а потому нам достаточно просто предсказать ситуацию, когда данный метод будет инициировать исключение. Для того, чтобы basic_ostream::operator<<(int val) мог генерировать исключение, необходимо чтобы пользователь класса basic_ostream предварительно (т.е. до вызова basic_ostream::operator<<(int val)) сделал вызов basic_ios::exceptions(iostate) с параметром ios_base::badbit и/или ios_base::failbit.
void test(std::ostream& out) { out.exceptions(std::ios_base::failbit | std::ios_base::badbit); const int temp = 1; // Следующая строка может генерировать исключение out << temp; }
Для того, чтобы составить представление о возможных типах исключений, нам понадобится упомянуть о внутреннем классе basic_ostream::sentry (раздел 27.7.3.4 стандарта ISO/IEC 14882:2011). Стандарт обязывает метод basic_ostream::operator<<(int val) начинать свою работу с создания объекта класса basic_ostream::sentry. Это необходимо для выполнения безопасных с точки зрения исключений операций при входе и выходе из функции [exception safe prefix and suffix operations].
Так вот, при создании объекта класса basic_ostream::sentry может быть вызван метод basic_ios::setstate() с параметром ios_base::failbit. Вызов этого метода может генерировать исключение ios_base::failure, если пользователь сделал предварительный вызов basic_ios::exceptions(iostate) с параметром ios_base::failbit.
Кроме этого сценария, стандарт также описывает ситуацию, когда исключение может возникнуть в момент вывода данных методом basic_ostream::operator<<(int val). Если подобное происходит, стандарт обязывает метод basic_ostream::operator<<(int val) перехватить исключение. После того как исключение перехвачено, метод должен проанализировать выражение (basic_ios::exceptions()&ios_base::badbit). Если данное выражение не равно нулю, метод должен позволить дальнейшее распространение исключения. Упомянутое выше выражение будет не равно нулю лишь в том случае, если пользователь сделал предварительный вызов basic_ios::setstate() с параметром ios_base::badbit.
Очевидно, что те же результаты даст нам исследование basic_ostream::operator<<(float val). Что касается basic_ostream::operator<<(basic_ostream& os, const basic_string& str), то хотя он и описан в разделе, посвящённом библиотеке строк (раздел 21.4.8.9), тем не менее, к нему применяются те же требования, как к функциям форматированного вывода (раздел 27.7.3.6.1 стандарта ISO/IEC 14882:2011).
Итак, подведём итог. Все упомянутые выше методы basic_ostream::operator<< могут генерировать исключение. Необходимым условием для этого является предварительный вызов basic_ios::exceptions(iostate) с параметром basic_ios::badbit и/или параметром basic_ios::failbit. Типом исключения будет либо ios_base:: failure либо тип, унаследованный от std::exception (напомню, что это базовый тип для всех исключений, генерируемых стандартной библиотекой).
Таким образом, у нас выбор между двумя реализациями метода Serialize.
В первом случае мы оставляем тело Serialize в виде, приведённом выше. Это позволит клиенту класса UserPreferences принимать решение: желает ли он обрабатывать значение, возвращаемое методом Serialize, либо ему удобней перехватывать исключение, если в процессе вывода случилась ошибка.
Во втором случае тело метода Serialize необходимо изменить для того, чтобы добиться безусловной генерации исключений в случае ошибки.
Сперва обсудим первый вариант реализации Serialize, при котором тело метода остаётся неизменным, и посмотрим на клиентский код. Так может выглядеть код клиента при использовании возвращаемого значения — “возвращаемым” значением в данном случае будет входной (он же выходной) параметр std::ostream:
void MainApplication::SaveUserPreferences(const UserPreferences& userPreferences) { std::ostringstream out; out << userPreferences; // здесь мы используем basic_ios::operator bool() // чтобы узнать чем закончилась последняя операция вывода if(out) { saveToDisk(out); } else { DisplayError("Failed to save user preferences"); } }
А так будет выглядеть код клиента, если ему более по душе перехват исключений – для генерации исключения клиент должен задействовать вызов basic_ios::exceptions():
void MainApplication::SaveUserPreferences(const UserPreferences& userPreferences) { std::ostringstream out; out.exceptions(std::ios_base::failbit | std::ios_base::badbit); // здесь мы полагаемся на генерацию исключения // в случае возникновения ошибки вывода try { out << userPreferences; saveToDisk(out); } catch(std::exception&) { DisplayError("Failed to save user preferences"); } }
Теперь рассмотрим альтернативный вариант Serialize, о котором говорилось выше – мы добавим безусловную генерацию исключений при возникновении ошибки:
void UserPreferences::Serialize(std::ostream& out) const { const std::ios_base::iostate previousState = out.exceptions(); out.exceptions(std::ios_base::failbit | std::ios_base::badbit); try { out << mIntValue << ' ' << mStringValue << '\0' << mFloatValue << ' '; } catch(std::exception&) { // Мы перехватываем исключение лишь с одной целью - // восстановить состояние “реакции на ошибки” out.exceptions(previousState); throw; } out.exceptions(previousState); }
И код клиента для данного варианта Serialize сводится к виду:
void MainApplication::SaveUserPreferences(const UserPreferences& userPreferences) { std::ostringstream out; // мы исходим из того, что Serialize всегда генерирует исключение в случае ошибки try { out << userPreferences; saveToDisk(out); } catch(std::exception&) { DisplayError("Failed to save user preferences"); } }
Что ж, давайте перечислим все “за и против” обоих подходов и сделаем выводы.
Первый подход – решение о генерации исключения принимает код, вызывающий Serialize
Плюсы:
- Код метода Serialize прост и компактен
- Клиент управляет политикой распространения ошибок: исключение или возвращаемое значение
- Политика распространения исключений совместима с методами operator<<, объявленными в пространстве имён std
Минусы:
- Ошибка, случившаяся в процессе вывода, может быть проигнорирована вызывающим кодом
Второй подход – метод Serialize генерирует исключение, если в процессе вывода случилась ошибка
Плюсы:
- Невозможно проигнорировать ошибку
Минусы:
- Код метода Serialize неуклюж и многословен, а потому провоцирует ошибки кодирования
- Пользователь класса UserPreferences должен будет добавить в свой код обработку исключительных ситуаций, если он этого ещё не сделал
- Поведение метода operator<< (std::ostream& out, const UserPreferences& userPreferences) несовместимо с поведением методов operator<< из пространства имён std
Невозможность проигнорировать ошибку может оказаться неоспоримым преимуществом: вероятно, существует сценарий, в котором необработанная ошибка грозит катастрофическими последствиями (к сожалению, в голову не приходит подходящего примера). Но с практической точки зрения первый подход выглядит более привлекательно: он даёт возможность выбора пользователю класса, упрощает кодирование и обеспечивает согласованное поведение operator<<. В реальном проекте, я бы стал руководствоваться именно этими соображениями и остановился на первом (простом) варианте метода Serialize.
Добавлю ещё пару слов к обсуждению данной версии метода Serialize:
void UserPreferences::Serialize(std::ostream& out) const { out << mIntValue << ' ' << mStringValue << '\0' << mFloatValue << ' '; }
Здесь необходимо пояснить, что происходит в этом методе если ошибка случается при выводе очередного значения, скажем, значения – mIntValue. Это тривиально, но позволит нам расставить все точки над i. Будь “включена” генерация исключений, ответ был бы очевиден: неудача во время операции вывода приводит к генерации исключения, и выполнение последующих инструкций в данном методе прекращается. Но поскольку исключения “выключены”, постольку следующим вызовом, после закончившегося неудачей вызова basic_ostream::operator<<(int), будет вызов метода basic_ostream::operator<<(basic_ostream&,char), который в качестве входного параметра получит поток с установленными failbit или badbit. В этом случае, проверив состояние потока и обнаружив, что оно не равно goodbit, метод basic_ostream::operator<< просто вернёт управление в вызываемую функцию. (Это верно для библиотечных методов; разработчик, определяющий собственный operator<< должен позаботиться об этом сам). Что есть благо, поскольку избавляет нас от написания следующего кода:
void UserPreferences::Serialize(std::ostream& out) const { out << mIntValue; if(out) { out << ' '; if(out) { out << mStringValue; if(out) { out << '\0'; if(out) { out << mFloatValue; if(out) { out << ' '; } } } } } }
UserPreferences UserPreferences::UnSerialize(std::istream& in)
Ошибка, которая при работе с потоками вывода выглядит как экзотика, в случае потоков ввода становится обычным явлением. При выводе данных ошибки зачастую определяется средой – нехватка памяти, невозможность открытия описателя, нарушения прав доступа и т.п. – и случаются сравнительно редко. При считывании же данных основная причина ошибок – некорректный/неожиданный формат данных и случаются они заметно чаще. Хорошая новость состоит в том, что на реализацию класса UserPereferences данный факт не влияет.
Принцип работы basic_istream::operator>> аналогичен принципу работу basic_ostream::operator<<. То есть, мы можем заставить библиотеку генерировать исключение, сделав предварительный вызов basic_ios::exceptions(iostate). Иначе нам необходимо проверять состояние потока, чтобы выяснить успешно ли закончилась последняя операция.
Так же как и для basic_ostream::operator<< мы можем безопасно вызывать basic_istream::operator>> даже если объект класса basic_istream находится в “плохом” состоянии – вызов basic_istream::operator>> не изменит состояние переменной, в которую мы пытаемся прочитать значение.
Таким образом, мы встаём перед тем же выбором, что и при обсуждении метода Serialize – использовать ли для проверки результа метод basic_ios::operator bool() либо, в случае ошибки, генерировать исключение.
Вариант метода UnSerialize, использующего basic_ios::operator bool() (или “простой” вариант – по аналогии с “простым” вариантом Serialize) будет выглядеть следующим образом:
UserPreferences UserPreferences::UnSerialize(std::istream& in) { int intValue; in >> intValue; in.ignore(); std::string stringValue; Read(in, stringValue); float floatValue; in >> floatValue; in.ignore(); return UserPreferences(intValue, stringValue, floatValue); }
Второй вариант – вариант, генерирующий исключение – будет отличаться лишь установкой и восстановлением реакции на ошибки:
UserPreferences UserPreferences::UnSerialize(std::istream& in) { const std::ios_base::iostate previousState = in.exceptions(); in.exceptions(std::ios_base::failbit | std::ios_base::badbit); try { int intValue; in >> intValue; in.ignore(); std::string stringValue; Read(in, stringValue); float floatValue; in >> floatValue; in.ignore(); return UserPreferences(intValue, stringValue, floatValue); } // Мы знаем, что как конструктор класса UserPreferences, // так и basic_istream::operator>> могут генерировать лишь // исключения унаследованные от std::exception. catch (std::exception&) { in.exceptions(previousState); throw; } }
Все соображения, приведённые при обсуждении метода Serialize, остаются верными и для метода UnSerialize, и поэтому мы уже знаем ответ на незаданный ещё вопрос: какой из этих методов нам следует выбрать. Мы должны выбрать ту реализацию, которая соответствует реализации метода Serialize. Для метода Serialize, напомню, мы выбрали реализацию, совместимую с basic_ostream::operator<<. Таким образом, метод UnSerialize должен быть совместим с basic_istream::operator>>. Разработчику, который решился на вариант Serialize, генерирующий исключение, по всей видимости понадобится вариант UnSerialize, который также генерирует исключение.
Для того, чтобы обсуждение было полным, необходимо сказать несколько слов о методе basic_istream::ignore(). Метод относится к группе функций неформатированного ввода [unformatted input functions] (раздел 27.7.2.3 стандарта ISO/IEC 14882:2011). Требования к этой группе слегка отличаются от требований, выдвигаемых к группе функций форматированного ввода [formatted input functions] (раздел 27.7.2.2.1 стандарта ISO/IEC 14882:2011), к которой относится метод basic_istream::operator>>. Но эти отличия не касаются процесса генерации исключений, а потому всё, что мы говорили о методе basic_istream::operator>>, остаётся справедливым и касательно метода basic_istream::ignore().
void UserPreferences::Read(std::istream& in, std::string& out)
Чтобы завершить обсуждение класса, нам осталось взглянуть на метод Read, который отвечает за считывание строк:
void UserPreferences::Read(std::istream& in, std::string& out) { char c; while(in.get(c)) { if(c==0) { break; } out.push_back(c); } }
В этом методе нам встречаются две функции, не обсуждавшихся ранее – это basic_istream::get(char_type& c) и basic_string::push_back(char_type c).
Первый из этих методов также относится к группе функций неформатированного ввода [unformatted input functions], то есть, с точки зрения генерации исключений не отличается от рассмотренного выше basic_istream::ignore(), а потому мы не останавливаемся на его обсуждении. Хотя нужно отметить, что реализация метода Read останется неизменной вне зависимости от выбранного способа распространения ошибок – генерация исключения или анализ возвращаемого значения. Это объясняется тем, что метод Read может быть вызван только из метода UnSerialize, и поэтому возможность или невозможность генерации исключения из метода basic_istream::get(char_type& c) будет определять реализацией UnSerialize.
Что касается метода basic_string::push_back, то помимо ожидаемого std:bad_alloc он может генерировать std::length_error в случае, если длина результирующей строки превышает basic_string::max_size(). Данным аспектом мы управлять не можем, поэтому нам остаётся лишь зафиксировать тот факт, что, в дополнение ко всему выше сказанному, метод Read может генерировать исключения типов std:bad_alloc и std::length_error.
Окончательный вид класса UserPreferences
Давайте посмотрим, что же мы имеем в итоге. Ниже ещё раз приведено описание класса – на этот раз к каждому методу добавлен комментарий.
class UserPreferences { public: // Может генерировать std::exception UserPreferences() // Может генерировать - std::exception UserPreferences(int intValue, const std::string& stringValue, float floatValue); // Метод не генерирует исключений void Serialize(std::ostream& out) const; // Может генерировать std::exception по причине создания объектов типов std::string и UserPreferences // Может генерировать std:bad_alloc и std::length_error по причине вызова Read static UserPreferences UnSerialize(std::istream& in); private: // Может генерировать std:bad_alloc и std::length_error static void Read(std::istream& in, std::string& out); private: int mIntValue; std::string mStringValue; float mFloatValue; }; // Метод не генерирует исключений std::ostream& operator<< (std::ostream& out, const UserPreferences& userPreferences); // Может генерировать std::exception, std:bad_alloc и std::length_error std::istream& operator>>(std::istream& in, UserPreferences& userPreferences);
Любопытно отметить, что мы пришли к виду класса, в котором информация об ошибках распространяется как посредством генерации исключений, так и через возвращаемые функциями значения (точнее, посредством входных/выходных параметров std::ostream и std::istream).
Но, что важно отметить, данные ошибки принадлежат двум различным категориям.
Ошибки первого рода могут быть обработаны непосредственно клиентом класса UserPreferences. Например, столкнувшись с проблемой восстановления предварительно сохранённых параметров, клиент класса UserPreferences может попытаться использовать значения по умолчанию либо обратиться за помощью к конечному пользователю приложения.
Ошибки второго рода можно отнести к ошибкам катастрофическим с точки зрения приложения, от которых клиент класса UserPreferences вряд ли сможет самостоятельно восстановиться. Даже предположив, что клиент класса UserPreferences, перехватив исключения типа std:bad_alloc, попытается для продолжения работы загрузить значение по умолчанию, нет гарантии, что при создании требуемого значения клиент не столкнётся с повторной генерацией того же исключения std:bad_alloc, которое приведёт к аварийному закрытию приложения.
Забегая немного вперёд, скажу, что подобное деление ошибок на те, которые могут быть обработаны неким классом (в данном случае – клиентом класса UserPreferences) и те, которые класс обработать не в состоянии, не является характерной особенностью нашего примера. Оно, скорее, присуще всем системам, составленным из разных уровней абстракций, когда для выполнения некоторого “бизнес-действия” привлекается “низкоуровневая” реализация, а при возникновении ошибки, с которой “работник” справиться не в состоянии, происходит обращение за помощью к вызывающему коду.
Продолжение следует…
Предыдущая часть опубликована здесь.