Типичные сценарии распространения и обработки исключений
Артур Бакиев | 14.02.2011
Предисловие
Статья рассматривает вопросы, относящиеся к обработке исключительных ситуаций [exception handling] в языках, поддерживающих соответствующий механизм. В статье обсуждаются наиболее распространённые проблемы, с которыми сталкивается разработчик, применяя обработку исключений, а также возможные способы решения этих проблем. Основной акцент в статье делается на примерах и прецедентах использования исключений. Статья предназначена для разработчиков, знакомых с объектно-ориентированными языками и будет полезна при освоении этих языков.
Основные примеры в статье приведены на C++.
Введение
Идеализированный подход, использующий обработку исключений подразумевает следующее. Разработчик пишет код так, как если бы в нём не случались ошибки. За обработку же ошибок отвечает некоторый заранее определённый код, который знает, как обработать исключительную ситуацию. Подобное разделение между кодом, выполняющим основную работу, и кодом обработки ошибок должно упрощать взаимодействие отдельных компонент между собой, позволяя строить изолированные/слабо-связанные уровни абстракций, и как следствие, уменьшать стоимость разработки и поддержки.
Понятия “ошибка” и “исключение” в статье, в основном, рассматриваются как синонимы. Везде, где не оговорено особо, фразу “функция генерирует исключение” можно трактовать, как “функция возвращает ошибку”, и наоборот. Либо, переиначив, функция извещает об ошибке, генерируя исключение.
В тех местах, где термины “ошибка” и “исключение” трактуются по-разному, различие очевидно из контекста. В этом случае, “ошибка” рассматривается как ситуация, сигнализирующая о том, что некоторая часть системы не смогла справиться с поставленной задачей. “Исключение” же рассматривается как транспортный механизм, позволяющий доставить по назначению информацию об ошибке.
Под термин “исключительная ситуация” не подводится теоретической базы – ситуация считается “исключительной”, если таковой её считает разработчик.
Видимо, определение того, что является “исключительной ситуацией”, в общем случае, является непростой задачей. Определение будет зависеть от явной (и неявной) семантики интерфейсов, от деталей реализации и пристрастий разработчика.
Как простой пример, можно рассмотреть разыменование недействительного указателя. Является ли подобная ситуация “исключительной”? На первый взгляд, да. С другой стороны, намерение разработчика могло состоять в локальном перехвате нарушения доступа [access violation] используя структурную обработку исключений [structured exception handling - SEH] для выделения очередной страницы памяти. А это, скорее, попадает в категорию “алгоритм”.
Помимо этих понятий, в статье широко используется термин “компонент”. Термин следует трактовать в самом широком смысле – он может ссылаться на класс, подсистему, программный продукт и т.п.
1 Стратегия
Мы будем исходить из того, что язык позволяет нам генерировать и перехватывать исключения. Это необходимо и, в принципе, достаточно для реализации единого – последовательного и согласованного – подхода к обработке ошибок, о котором говорилось выше. Утверждение вытекает из уверенности в том, что подход реализуем, в рамках одного проекта, поддерживаемого одним человеком.
Посмотрим, жизнеспособна ли идея, если речь идёт о промышленном применении.
Для того, чтобы решение выглядело чем-то большим, нежели совет “пишите хороший код”, начнём со списка вопросов, которые встают перед разработчиком в момент обработки ошибочной ситуации:
- Прежде всего, необходимо определиться – следует ли возбуждать исключительную ситуацию в данной конкретной точке или можно воспользоваться традиционным возвращаемым значением.
- Если решение о генерации исключения принято – необходимо решить, понадобится ли нам идентифицировать ошибку либо достаточно факта существования исключения и ошибка может быть анонимной.
- Далее, нужно определиться с тем, где исключение будет перехвачено.
- Необходимо убедиться в корректной обработке исключения.
- Следует “автоматизировать” генерацию и обработку исключений. Давать ответы на предыдущие 4 вопроса в каждой точке, где может быть инициировано исключение – занятие утомительное и потому, подвержено ошибкам (кодирования).
- Нельзя упустить вопрос эффективности работы с исключениями – мы не хотим платить за то, что мы не заказывали.
Вопросы перечислены приблизительно в том порядке, в каком они возникают перед разработчиками. Способ описания, выбранный в статье, согласуется с этим порядком. Далее будет подробно рассмотрено каждое из требований и сформулирован общий подход и рекомендации.
1.1 Когда следует генерировать исключения
Начнём с первого вопроса в нашем списке – следует ли в данной точке воспользоваться исключением или достаточно будет возвращаемого функцией значения. Сам по себе вопрос не сложен, но в реальных проектах решение о том, следует ли генерировать исключение, приходится принимать чаще, чем можно было бы ожидать, поэтому остановимся на нём подробно.
Два фактора влияют на выбор того или иного способа:
- Ограничения, которые накладываются внешними условиями.
- Ограничения, которые определяются назначением компонента.
Первый не зависит от разработчика и, порой, не оставляет ему выбора, заставляя использовать традиционные методы оповещения об ошибках. Тогда как второй определятся прецедентами использования [use cases] разрабатываемого компонента.
Ниже мы подробно рассмотрим оба фактора.
1.1.1 Внешние ограничения
Начнём с рассмотрения внешних ограничений. Подобные ограничения актуальны для языка C++, но подчас их приходится учитывать и для языков, для которых работа с исключениями является “врождённым” свойством. Вот основные случаи, в которых нам приходится использовать традиционные способы оповещения об ошибках.
- Создание функции (обычно C++), вызываемой из другого языка программирования (экспортируемая функция). Клиентами могут выступать Visual Basic, C, C++, Java и т.п.
- Создание функции обратного вызова (callback функции). Функция может вызываться как операционной системой, так и библиотечным кодом. Оконная процедура для платформы Windows может служить примером подобной функции. Функцию main языка C++ также можно рассматривать как специальную форму функции обратного вызова. Для платформы Android примерами будут являться callback-интерфейсы, широко используемые в пространстве имён android.
- Использование определённой технологии, запрещающей использование исключений. Например при создании модулей COM на языке C++ у разработчика не существует альтернативы: спецификация требует, чтобы ошибка поставлялась единственным способом – через результирующее значение функции, HRESULT.
Хотя перечисленные случаи, не разрешают использовать генерацию исключений для извещения об ошибках, внутри самих функций может, а, возможно, даже должен, производиться перехват исключений.
Изредка разработчики склонны забывать о соглашениях, принятых для функций обратного вызова. Подобные функции возвращают своё управление в библиотечный код/код операционной системы. Генерация исключения внутри такой функции может привести к неожиданным последствиям.
Хотя мы и можем ожидать, что разработчик библиотеки будет защищаться от подобного поведения клиентского кода, в общем случае следует исходить из того, что мы не имеем доступа к исходному коду библиотеки/операционной системы и не можем его контролировать. В качестве примера, рассмотрим следующий псевдокод (С++ и WinAPI).
LRESULT CALLBACK MyCallbackMethod(API_STRUCTURE*); void DoSomething() { API_STRUCTURE s = { 0 }; try { API_Method(&s, MyCallbackMethod ); } catch(const MyError&) { // #1 Another_API_Method(&s ); } } LRESULT CALLBACK MyCallbackMethod(API_STRUCTURE*) { throw MyError( ); }
Точка #1 отчётливо демонстрирует неоднозначность возникающей ситуации в случае, когда MyError покидает пределы метода MyCallbackMethod:
- В первую очередь нужно отметить, что, генерируя исключения внутри функции MyCallbackMethod, мы не можем быть уверены, что будет достигнута точка #1.
- Кроме этого нужно добавить, что если точка #1 всё же будет достигнута, мы не можем полагаться на то, что данные – как те, что передаются через параметры, так и внутренние данные библиотеки – остались в согласованном состоянии.
1.1.2 Выбери меня
Теперь, предположив, что внешние ограничения отсутствуют, давайте рассмотрим, что может повлиять на наше решение – воспользоваться исключением или вернуть из метода значение?
Обсуждение полезно будет провести на примере небольшого класса. Подобное обсуждение даст нам возможность поставить “правильные” вопросы, задав которые мы в значительной мере приблизимся к решению. Начнём с рассмотрения примера совместно используемого кода (для краткости назовём такой код “библиотечным”). Это позволит нам в дальнейшем упростить обсуждение “обычного” небиблиотечного кода.
1.1.2.1 Создание библиотеки
Написание совместно используемого кода возлагает определённые обязательства на разработчика. Здесь мы коснёмся лишь тех, что относятся непосредственно к теме нашего обсуждения.
Как правило, в отсутствии внешних ограничений, разработчик волен сам выбирать способ оповещения об ошибках. Рассмотрим факторы, которые могут повлиять на этот выбор:
- Функциональная и логическая совместимость библиотеки (“похожесть”).
- Способ распространения библиотеки.
1.1.2.1.1 Функционально совместимый код
Под функциональной совместимостью здесь следует понимать совместимость создаваемого совместно используемого кода с базовым кодом, на котором построена библиотека (если таковой имеется) или кодом, который она призвана заместить.
Одним из примеров функционально совместимой библиотеки может послужить набор классов, обеспечивающих тонкую (а иногда и не очень тонкую) обёртку вокруг функций API. Здесь уместно вспомнить Windows Template Library (WTL).
Другой пример – дополнение или расширение хорошо известной библиотеки. Здесь знакомой иллюстрацией может быть библиотека boost.
В случаях, подобным упомянутым выше, наилучшей стратегией является соблюдение “принципа наименьшего удивления” [principle of least astonishment] [principle of least surprise] (E.S. Raymond, The Art of Unix Programming, 1.6.10 Rule of Least Surprise). Сейчас мы ведём речь лишь об обработке исключений, но, в общем случае, этот подход хорошо работает и при реализации библиотеки в целом.
Класс CFile
В качестве примера обратимся к классу, который работает с описателем файла – объектом ядра операционной системы Windows. И попытаемся на этом примере проиллюстрировать условия, которые могут повлиять на выбор определённой стратегии распространения ошибок.
Объявив класс в некотором пространстве имён, для того чтобы минимизировать конфликты с другими классами “файл”, в дальнейшем будем опускать это пространство имён. Выдвинем основное требование к данному классу – облегчить, насколько возможно, жизнь разработчикам-пользователям данного класса.
Первое, что мы можем потребовать от класса – это взять на себя скучную работу по закрытию описателя объекта ядра. Всё, что нам понадобится – это пара функций: конструктор-деструктор.
// пространство имён опущено class CFile { public: explicit CFile(HANDLE h); ~CFile(); operator HANDLE() const; private: CFile(const CFile&) throw(); CFile& operator=(const CFile&) throw(); private: HANDLE m_h; };
Рассмотрим каждую из функций класса с целью выяснить наиболее пригодный в данном случае способ распространения ошибок.
CFile::operator HANDLE()
Выше я немного слукавил. Сказав, что нам понадобится лишь пара функций – конструктор и деструктор, я, на самом деле, описал в классе 5 методов. Закрытые конструктор копирования [copy constructor] и оператор присваивания [assignment operator] мы ещё обсудим. Начнём же мы, пожалуй, с метода CFile::operator HANDLE().
Поскольку в классе не определено ни одной функции, через которую можно оперировать с описателем, нам понадобился метод CFile::operator HANDLE(). Метод позволит нам свободно передавать объект CFile в функции, ожидающие описатель. Также это позволит расширять класс постепенно, без необходимости реализовывать все аналоги функций API в момент создания класса.
Подобное раскрытие реализации (посредством функции CFile::operator HANDLE()), в общем случае, нежелательно, поскольку оно обеспечивает пользователю класса полный доступ к внутренней структуре класса, и не скрывает того, что данный класс является обёрткой описателя. Хотя от класса CFile именно это и требуется, такой подход накладывает и определённые обязательства. В данном случае, класс берёт на себя обязательство обеспечить семантическую эквивалентность поведения функций класса поведению “чистых” функций API, оперирующих с объектом операционной системы – “файл”. Ниже мы увидим о какой эквивалентности идёт речь.
С другой стороны, мы могли бы избежать объявления метода CFile::operator HANDLE(), реализовав в классе все аналоги API функций, предназначенных для работы с файлом (CreateFile etc.), а также аналоги всех функций, работающих с описателями объекта ядра “полиморфно”, без учёта их типа (таких как WaitForMultipleObjects). Но и в этом случае решение нельзя было бы назвать достаточным – следующая версия операционной системы может добавить новую функцию API, работающую с описателем ядра, и это заставит нас изменить определение класса (возможно, добавив метод CFile::operator HANDLE()).
С функцией CFile::operator HANDLE() дело обстоит проще всего. Имея приведённое выше описание класса, можно предположить, что функция не должна генерировать исключение.
inline CFile::operator HANDLE() const throw() { return m_h; }
И это действительно так, поскольку единственное место, где m_h может быть присвоено недействительное значение – это конструктор класса. И потому выглядит разумно, что именно этот метод (конструктор) должен нести ответственность за то, чтобы генерировать исключение (если будет выбран этот путь распространения ошибок).
Либо, давая более развёрнутое пояснение, предположим, что в конструктор класса было передано недействительное значение описателя ядра. В этом случае конструктор может как возбудить исключительную ситуацию, так и инициализировать член класса m_h недействительным значением. Но какой бы из вариантов ни был выбран при реализации конструктора, это не должно повлиять на реализацию метода CFile::operator HANDLE() и вот почему:
- Если при конструировании объекта будет возбуждена исключительная ситуация, то объект не будет создан и функцию CFile::operator HANDLE() вызывать будет не у кого.
- Если исключение не было инициировано в теле конструктора, но мы решим сгенерировать его внутри метода CFile::operator HANDLE(), мы, по всей видимости, усложним жизнь пользователям класса. Поскольку, вместо того, чтобы информировать их об ошибке в той точке, где она произошла (инициализация переменной недействительным значением в конструкторе класса), мы отсрочим диагностику, заставив их иметь дело с методом, который лишь читает неверное значение.
Таким образом метод CFile::operator HANDLE() может быть написан так, как если бы приватный член m_h был проинициализирован должным образом.
CFile::~CFile()
Перейдём к рассмотрению деструктора. Наверное, не ошибусь, сказав, что нас приучили не инициировать исключение в деструкторе и с подозрением смотреть на классы, практикующие данный подход.
Деструктор класс CFile, не генерирующий искючений, мог бы выглядеть следующим образом:
#ifdef _DEBUG inline BOOL Verify(BOOL b) throw() { if( !b ) { DWORD nErr = ::GetLastError( ); nErr; ::DebugBreak( ); } return b; } #else inline BOOL Verify(BOOL b) throw() { return b; } #endif // _DEBUG inline CFile::~CFile() throw() { Verify( ::CloseHandle( m_h ) ); }
Тем не менее, мы отвлечёмся на время от общей рекомендации не генерировать исключение в теле деструктора и внимательно рассмотрим причины, по которым не стоит этого делать.
Деструктор класса CFile мог бы возбудить исключительную ситуацию лишь в случае, если бы метод ::CloseHandle вернул ошибку. Вызов же функции ::CloseHandle может вернуть ошибку по одной из следующих причин.
Причина первая – в конструктор CFile был передан недействительный описатель. В этом случае нам необходимо повторить ту же цепь рассуждений, что мы провели для метода CFile::operator HANDLE() и прийти к выводу, что если исключение и должно возникнуть, то в конструкторе CFile:CFile, но никак не в деструкторе CFile::~CFile.
Причина вторая. Объект CFile был создан с использованием действительного описателя, но ::CloseHandle, тем не менее, вернул ошибку (например, по причине нарушения прав доступа). В этом случае следует руководствоваться следующим практическим соображением – выполнение кода деструктора означает, что переменная покидает область видимости, и в большинстве случаев не имеет значения, успешно или неуспешно завершился вызов ::CloseHandle. Безусловно, нельзя полагаться на то, что всех разработчиков устроит подобное поведение (о тех, кому важны результаты закрытия описателя мы поговорим ниже). Нельзя, также, рассчитывать на то, что код, который будет исполняться в случае ошибки в отладочной версии приложения, исправит ситуацию.
Но давайте на секунду представим деструктор объекта CFile, который генерирует исключение. Код должен был бы выглядеть примерно следующим образом.
inline CFile::~CFile() throw(CFileError) { if( Verify( ::CloseHandle( m_h ) ) ) return; if( std::uncaught_exception( ) ) return; const DWORD nErr = ::GetLastError( ); throw CFileError( nErr ); }
Прежде всего, следует отметить, что и в этом случае нельзя гарантировать того, что пользователь сможет получить оповещение о неуспешной попытке закрыть объект ядра.
Проверка результата вызова std::uncaught_exception() необходима: она защищает код от попытки вызвать повторную генерацию исключения в процессе раскрутки стека, когда исключение уже существует. Если же позволить повторную генерацию, это будет считаться ошибкой механизма обработки исключений (ISO/IEC 14882:2003 раздел 15.5.1) и, по умолчанию, будет вызвана функция std::terminate(), которая, в свою очередь, по умолчанию, вызовет std::abort().
Нельзя назвать это и самым изящным способом оповещения об ошибке. Корень неоднозначности кроется в том, что в общем случае невозможно определить, можно ли проигнорировать одно исключение ради обработки другого. Именно поэтому стандарт настоятельно не рекомендует генерировать исключения в деструкторе объекта (ISO/IEC 14882:2003 раздел 15.2.3).
Таким образом, можно утверждать, что отсутствует основная предпосылка для использования исключения в деструкторе – повышение надёжности кода путём своевременного оповещения об ошибке. Или, другими словами, поскольку существует ситуация, при возникновении которой ошибка не может быть донесена до пользователя, постольку необходим другой, надёжный способ донести ошибку.
От вопроса о необходимости генерировать исключение перейдём к минусам, получаемым от использования деструктора, возбуждающего исключительную ситуацию.
- В первую очередь замечу, что не приходится говорить о том, что подобная модель распространения ошибок облегчает жизнь разработчикам. Любой, кто пожелает использовать класс CFile для автоматического закрытия описателя, будет вынужден обрабатывать исключения, которые могут возникнуть в деструкторе объекта, независимо от того, важно ему это или нет.
- Далее, необходимо упомянуть о потенциальной утечке памяти в operator delete [ ]. Если при разрушении объекта, размещённого в динамически выделенном массиве, возникнет исключение, то, скорее всего, это выльется в невозможность вызова деструкторов для объектов с меньшими значениями индексов, а память, выделенная под массив, не будет возвращена системе.
- Кроме этого, использование исключений в деструкторе заставит отказаться от использования определённых техник программирования. Например, техника безопасного с точки зрения исключений конструктора, описанная H.Sutter’ом (H.Sutter “Exceptional C++ and More Exceptional C++”), исходит из того, что деструктор объекта не распространяет исключений.
- Также объект класса CFile не сможет быть использован в качестве глобального статического объекта [non-local object with static storage duration] (ISO/IEC 14882:2003 всё тот же раздел 15.5.1).
- Ко всему прочему, могут оказаться важными особенности используемого компилятора. Например, при использовании среды разработки Visual Studio 6.0 последний вариант деструктора всегда будет генерировать исключение (в случае, если ::CloseHandle() вернёт NULL) по той причине, что функция std::uncaught_exception() в данной реализации всегда возвращает false.
Подводя итог, можно сказать, что вариант деструктора, не использующего исключения – это наилучший вариант деструктора. Представляется, что приведённые выше доводы остаются верными при обсуждении любого класса. Потому, в дальнейшем, политика распространения исключений деструкторами не обсуждается и молчаливо предполагается, что все описанные в статье классы используют пустую спецификацию исключений для деструкторов.
CFile::CFile(HANDLE h)
По всей видимости, политику распространения исключений будет определять реализация конструктора, поскольку именно она влияет на поведение функций, обсуждавшихся выше. В предложенном описании класса это единственная функция, модифицирующая приватный член класса.
Рассматривая конструктор, невозможно сослаться на то, что обработка ошибки должна вестись где-то в другом месте (как в случае с CFile::operator HANDLE()). Также нельзя привести пример неоднозначного, или вводящего в заблуждение кода (как в случае с деструктором).
Таким образом мы стоим перед выбором:
- Объявив конструктор с явной спецификацией throw(CFileError), мы укажем на то, что входной параметр проверяется, и, в случае его недействительности, будет возбужена исключительная ситуация.
- Объявив же конструктор с пустой спецификацией throw(), мы возложим всю ответственность за передачу неверного параметра на пользователя.
Оба варианта имеют право на существование. Вариант (1) – по причине того, что генерация исключения – чуть ли не единственный “легальный” способ уведомить об ошибке из конструктора. Вариант (2) возможен из-за неформального правила, позволяющего библиотеке не обрабатывать ошибки, которые могут быть обработаны в коде пользователя. При выборе той или иной стратегии приходится пользоваться общими соображениями.
Решая вопрос о способе распространения ошибки из конструктора, необходимо помнить о требованиях, выдвинутых к классу. Предназначение класса – облегчить жизнь тем разработчикам, которым приходится оперировать с объектом операционной системы – “файл”. С этой точки зрения и рассмотрим плюсы обеих реализаций. Плюс одного подхода является ничем иным, как минусом другого. Поэтому наборы минусов из перечисления исключим.
Плюсом первого подхода видится единственный пункт:
- Пользователь класса CFile избавлен от необходимости явно проверять допустимость входного параметра.
try { CFile file( ::CreateFile( /*...*/ ) ); } catch(const CFileError& e) { }
Плюсов у второго подхода, как видится, больше:
- Отсутствует необходимость проверять входной параметр в библиотечном коде. В общем случае неясно, что считать неверным входным парамером – только ли значение INVALID_HANDLE_VALUE, или нулевой указатель также следует отнести к этому набору? А как насчёт неинициализированной локальной переменной – следует ли её рассматривать как некорректный входной параметр? Последний вариант, видимо, не может быть реализован без включения платформенно-зависимого кода и/или добавления дополнительных расходов времени выполнения. В обоих случаях, решение может рассматриваться как неудовлетворительное.
- Нет необходимости вводить в код пользователя обработку исключений. Это даёт простоту использования класса в “смешанном”, или унаследованном коде. Под “смешанным” кодом здесь подразумевается код, в котором отсутствует регулярный подход к обработке ошибок. Исключения в подобном коде генерируются, в основном, случайным образом. Для сигнализации об ошибке может использоваться как возвращаемое функцией значение (в обычном значении), так и генерация исключения. Такая ситуация возникает, обычно, в результате долгой поддержки унаследованного кода, но нередко может определяться общей (неудачной) архитектурой приложения. Если обработка ошибок не производилась систематически, использование класса, инициирующего исключения может существенным образом сказаться на поведении приложения.
- Облегчение рефакторинга. По сути, этот пункт, является ничем иным, как следствием предыдущего. Отсутствие необходимости вводить поддержку обработки исключений в код, который изначально не был рассчитан на это, минимизирует количество ошибок, которые могут возникнуть при рефакторинге.
Следующий пример поможет проиллюстрировать проблему, описанную в двух последних пунктах.
bool CMyClass::Foo() { HANDLE h = ::CreateFile( /*...*/ ); // #1 CFile file( ::CreateFile ) ToDoSomethingUnrelatedWithCreatedFile( ); // #2, эта строка // выполняется всегда if( INVALID_HANDLE_VALUE == h ) return false; bool bRet = ToDoSomethingWithCreatedFile( h ); ::CloseHandle( h ); return bRet; }
Предположим, что в приведённом выше коде мы заменили вызов функции ::CreateFile() (строка #1) созданием объекта типа CFile, который генерирует исключение в конструкторе.
В этому случае, функция CMyClass::ToDoSomethingUnrelatedWithCreatedFile() (строка #2) будет вызвана лишь в случае успешного открытия файла, что, в свою очередь, может неявным образом изменить результат действия функции CMyClass::Foo(). Данный код, безусловно, является результатом небрежного программирования, но, к сожалению, в “смешанном” коде могут встречаться подобные примеры.
Создатель класса, рассматривая общий случай использования, обычно может оперировать контекстом, в котором, предположительно, класс будет использоваться наиболее часто. И потому видится, что, хотя проектировщик библиотеки и не может знать все места, где будет использоваться его код, он может сформулировать разумные предположения, в том числе, касающиеся обработки исключений.
Подведём итог. Плюсы второго подхода можно кратко сформулировать следующим образом – класс не навязывает обработку исключений. Подобное поведение хорошо согласуется с тем, что класс представляется ничем иным, как объектной заменой обычному описателю. Объект CFile может использоваться в любом месте, где может использоваться HANDLE, попутно предоставляя удобный сервис, которым славится объектный подход. И потому окончательный выбор будет сделан в пользу версии, не генерирующей исключений – класс CFile будет содержать конструктор CFile::CFile(HANDLE h) с пустой спецификацией throw().
CFile::CFile(const CFile&) и CFile& CFile::operator =(const &)
Нам осталось рассмотреть два последних метода – конструктор копирования и оператор присваивания. Функции настолько сильно семантически связаны между собой, что о них необходимо говорить совместно, что мы и будем делать.
Оба метода объявлены как закрытые и с пустой спецификацией throw(). Таким образом, мы подразумеваем, что:
- Методы могут быть вызваны лишь функцией-членом данного класса, и
- Методы не генерируют исключений.
Подобное объявление сделано по одной причине – у данных функций отсутствует реализация, поэтому они не могут быть вызваны, и, следовательно, они не могут инициировать исключение. А реализация, в свою очередь, у них отсутствует, потому что мы хотим избавиться от вопроса о том, что должно происходить при копировании объекта типа CFile. Данный вопрос не представляется тривиальным – как разработчики класса мы могли бы использовать любую из перечисленных ниже возможностей:
- Не объявлять функции. Код для обеих функций, при необходимости, будет создан компилятором.
- Объявить функции закрытыми и оставить их без реализации.
- Реализовать семантику владения описателем каждым из объектов класса CFile (a-la std::auto_ptr).
- Реализовать семантику разделения описателя между объектами класса CFile (a-la boost::shared_ptr).
- Использовать вызов ::DuplicateHandle() для создания дубликата описателя.
- Вынести всю семантику в какое-то другое место (параметризовать решение).
Выбор одной из этих линий поведения – отдельный вопрос. Для наших целей достаточно остановиться на втором пункте, т.к. реализация остальных только усложнит пример, не добавив ничего нового к теме обсуждения.
Этот интересный вопрос о том, какую из упомянутых возможностей следует выбрать, заслуживает отдельной темы. Здесь можно лишь заметить, что отсутствие реализации конструктора копирования и оператора присваивания хорошо подходит для случаев, когда объект класса CFile является локальной переменной или членом класса. (Необходимо однако помнить, что для последнего случая в описании класса CFile понадобится пара функций имеющих семантику Attach()/Detach() для того, чтобы разрешить копирование охватывающего класса.). Поместить объект класса CFile в STL’ный контейнер при отсутствии конструктора копирования, к сожалению, не удастся.
Кстати, конструкцию “приватность + отсутствие реализации” можно считать идиомой языка. Человеку, читающему код, становятся понятны намерения разработчика, описывающего конструктор копирования и оператор присваивания таким образом – класс не способен предоставить реализацию по умолчанию, приемлемую для всех пользователей данного класса. Подобную неоднозначность поведения иногда лучше описывать посредством специализированных функций – членов класса, имеющих хорошо подобранные имена (например, вышеприведённая пара – Attach()/Detach()). Но может статься и так, что семантику копирования и присваивания удобнее будет описывать, агрегируя и параметризуя примитивные классы.
Необходимо заметить, что в реальном проекте при выборе стратегии распространения ошибок двумя этими функциями (при условии, что реализация этих функций оказалось востребованной), потребуется повторить те же доводы, которые приводились при обсуждении конструктора. Таким образом, можно заключить, что в общем случае, для конструктора копирования и оператора присваивания следует выбирать такую же стратегию распространения исключений, как и для семейства обычных конструкторов, что, по крайней мере, будет наиболее близко соответствовать ожиданиям конечного пользователя класса.
Если подвести итог, то определение класса теперь выглядит следующим образом (все функции объявлены с пустой спецификацией throw()).
class CFile { public: explicit CFile(HANDLE h) throw(); ~CFile() throw(); operator HANDLE() const throw(); private: CFile(const CFile&) throw(); CFile& operator =(const CFile&) throw(); private: HANDLE m_h; };
Можно заметить, что у деструктора отсутствует спецификатор виртуальности. Здесь подразумевается, что данный примитивный класс будет листовым в дереве наследования (что, в принципе, не сможет удержать разработчика от использования данного класса в качестве базового).
Семейство классов
Конкретную задачу закрытия описателя можно решить гораздо более простым и элегантным способом – определив шаблонный класс, единственная задача которого – вызвать определённую функцию в своём деструкторе. Создание выделенного класса для объекта ядра может быть оправдано лишь в том случае, если класс предоставляет дополнительный сервис по сравнению с сервисом, что предоставляет пара “описатель и шаблон, закрывающий описатель”.
Потому, если речь идёт о промышленном применении, то данная реализация выглядит несколько наивно. Очевидно, что она определяет политику распространения ошибок ключевыми функциями класса внутри реализации данного класса. Как только возникнет необходимость определить класс, обладающей подобной семантикой для другого объекта ядра, нам понадобится повторить всю цепь рассуждений. Или воспользоваться распространённым инструментом разработчиков – механизмом “copy-paste”. По-видимому, наиболее перспективным решением для долгосрочных проектов было бы решение, параметризующее политику распространения исключений (в статье мы данное решение рассматривать не будем).
CFile::Create(/*…*/)
Заговорив о дополнительных сервисах, давайте оценим, как добавление новой функциональности повлияет на существующую стратегию распространения ошибок. Также интересно понаблюдать за тем, какие изменения придётся вносить в уже описанные функции.
Для начала, для уменьшения количества кода, необходимого для открытия файла, добавим в класс функцию CFile::Create().
class CFile { public: // новые методы CFile() throw(); HANDLE Create(LPCTSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes = 0, DWORD dwCreationDisposition = CREATE_ALWAYS, DWORD dwFlagsAndAttributes = FILE_ATTRIBUTE_NORMAL, HANDLE hTemplateFile = 0) throw(); // объявления “старых” методов остались без изменений }; inline CFile::CFile() throw() : m_h( INVALID_HANDLE_VALUE ) {} inline HANDLE CFile::Create(LPCTSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes/* = 0*/, DWORD dwCreationDisposition/* = CREATE_ALWAYS*/, DWORD dwFlagsAndAttributes/* = FILE_ATTRIBUTE_NORMAL*/, HANDLE hTemplateFile/* = 0*/) throw() { assert( INVALID_HANDLE_VALUE == m_h ); return m_h = ::CreateFile( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile ); }
Заметим, что добавление функции CFile::Create() заставляет нас добавить конструктор CFile::CFile() (к которому мы ещё вернёмся).
Из приведённого выше описания видно, что CFile::Create() объявлена с пустой спецификацией throw(). Обсудим это решение. Фактически, выбор в пользу распространения ошибок традиционным способом для всех функций класса был уже сделан при обсуждении конструктора. Описав функцию CFile::Create() как генерирующую исключение, мы оставили бы пользователя с несогласованным описанием класса:
HANDLE h = ::CreateFile( “file1.dat”, /*...*/ ); CFile file1( h ); // CFile::CFile(HANDLE h) // не генерирует исключений CFile file2; File2.Create( “file2.dat”, /*...*/ ); // предположим, что // CFile::Create() // может инициировать // исключительную ситуацию
В данном случае семантически эквивалентный код даёт разный результат и демонстрирует указанную неоднозначность. Предположив, что приведённый выше код присутствует в одной функции, будет трудно обосновать перед конечным пользователем класса разницу в поведении.
CFile::CFile()
Как было отмечено выше, добавление функции CFile::Create() приводит к необходимости описать конструктор по умолчанию [default constructor]. Это, в свою очередь, приводит к тому, что приватный член класса получает инициализирующее значение, которое остальные функции могли и не ожидать. В общем случае, добавление (или изменение) инициализирующего значения приватного члена приводит к пересмотру и, возможно, модификации всех функций, использующих этот член класса. В данном случае, член m_h инициализируется значением INVALID_HANDLE_VALUE, что необходимо приводит к изменению реализации деструктора. После модификации он должен выглядеть примерно так:
CFile::~CFile() throw() { if( INVALID_HANDLE_VALUE == m_h ) return; Verify( ::CloseHandle( m_h ) ); }
До введения конструктора умолчания предполагалось, что приватный член m_h не может получить значения INVALID_HANDLE_VALUE иначе, как по невнимательности пользователя класса. Теперь же это значение становится разрешённым в контексте объявления конструктора по умолчанию. Более тщательный, можно даже сказать “маниакальный”, подход подразумевает добавление ещё одного члена класса, который указывал бы на то, был ли член m_h инициализирован в результате вызова конструктора по умолчанию, либо был изменён внутри какой-либо другой функции. Это дало бы нам возможность в деструкторе провести различие между разрушением переменной, созданной конструктором по умолчанию, и неверно инициализированной переменной (имеющей то же значение INVALID_HANDLE_VALUE).
Но, кажется, что подобная тщательность выглядит излишней. Поскольку у деструктора нет способа сообщить о неудачном закрытии описателя (кроме как в отладочной версии приложения), постольку пользователи, которым важно знать о такой ситуации, будут вынуждены явно закрывать описатель, не полагаясь на деструктор.
Можно заметить, что после добавления конструктора по умолчанию, нет необходимости модифицировать функцию CFile::operator HANDLE(). Причины здесь те же, что приводились и раньше при обсуждении этой функции.
CFile::Close()
Вернёмся к нуждам тех, кому необходимо знать о неуспешном закрытии описателя объекта ядра “файл”. Для них необходимо ввести явную операцию, закрывающую описатель. И класс теперь будет выглядеть так (код деструктора продублирован для сравнения).
class CFile { public: // новый метод BOOL Close() throw(); // объявления “старых” методов остались без изменений }; CFile::~CFile() throw() { if( INVALID_HANDLE_VALUE == m_h ) return; Verify( ::CloseHandle( m_h ) ); } BOOL CFile::Close() throw() { // #1 // if( INVALID_HANDLE_VALUE == m_h ) // return true; assert( INVALID_HANDLE_VALUE != m_h ); BOOL bRes = ::CloseHandle( m_h ); Verify( bRes ); if( bRes ) m_h = INVALID_HANDLE_VALUE; return bRes; }
Обратите внимание, что следует противостоять искушению объединить “общий” код в деструкторе и в функции CFile::Close. Задача деструктора – попытаться закрыть описатель и позволить дальнейшее исполнение. Задача явного вызова CFile::Close – закрыть описатель и сигнализировать об ошибке, если закрытие прошло неуспешно. Отсутствие проверки на допустимость описателя при входе в функцию CFile::Close() – отнюдь не оплошность. Разработчик, который не поленился и пожелал явно закрыть файл, либо занимался оптимизацией (освобождение ресурса до выхода из области видимости), либо для него важен результат закрытия. В этом смысле не должно быть никакой разницы между вызовом CFile::Close() и непосредственным вызовом ::CloseHandle().
Если бы мы проверяли значение описателя при входе в функцию CFile::Close() (раскомментировав две сточки, ниже #1), пользователь мог бы наблюдать разницу в побочных эффектах. А именно, вызов CFile::Close(), для неинициализированного объекта, не модифицировал бы значение, которое возвращает ::GetLastError(), а вызов ::CloseHandle() с тем же неинициализированным объектом в качестве параметра заносил бы в thread local storage (TLS) значение 6 (The handle is invalid).
Здесь также можно отметить схожесть объявлений неинициализированной переменной типа HANDLE и переменной типа CFile. Если обе переменные в дальнейшем нигде не используется, то единственный побочный эффект от их объявления – используемая память (возможно, на стеке). Для обеих переменных функция ::CloseHandle() вызвана не будет. Попытка же явно вызвать CFile::Close(), так же, как и попытка вызвать ::CloseHandle() для неинициализированной переменной, приводит к тому, что в TLS заносится информация об ошибке.
Общие замечания
На этом можно и остановиться. Описание класса, как примера, демонстрирующего конкретную стратегию распространения ошибок, выглядит логически законченным. Дальнейшее расширение функциональности класса потребует от разработчика только аккуратности, для обеспечения совместимого, с уже описанными функциями, поведения.
Следует отметить, что вопросы, связанные со способом распространения ошибок при написании класса CFile, возникли по той причине, что класс не скрывает своей реализации и пытается поддержать согласованное поведение с определённым семейством функций API. Эти дополнительные требования, накладываемые на реализацию класса, привносят описанную сложность. Далее мы увидим, что таких вопросов не возникает при создании класса, который не озабочен поддержкой функциональной совместимости.
Общая “проблема” подобных библиотек-обёрток состоит в том, что в подавляющем количестве случаев, они будут служить чисто утилитарной цели – уменьшение объёма кода в одной конкретной функции (классе, компоненте). И у библиотеки будет гораздо больше шансов быть повторно используемой, если пользователю не придётся изучать её структуру и сопоставлять поведение библиотеки с поведением “чистых” функций API. Генерация исключения и, соответственно, его перехват, могут рассматриваться как попытка навязать дополнительную и, возможно, ненужную пользователю работу.
В заключение можно заметить, что приведённый пример следует принципу наименьшего удивления:
- Функции не замещают результаты вызовов (относительно функций API). Например, могло бы возникнуть желание возвращать из функции CFile::Create() значение BOOL, вместо HANDLE.
- Функции не изменяют порядок параметров. В той же функции CFile::Create() порядок аргументов не отличается от порядка аргументов функции ::CreateFile(). Хотя, казалось бы, логично поменять местами параметры lpSecurityAttributes и dwCreationDisposition, так как значение dwCreationDisposition, возможно, будет меняться чаще, чем lpSecurityAttributes (который, обычно, будет равен нулю).
- Вызов функций не приводит к появлению новых побочных эффектов, по сравнению с вызовом функций API, и не приводит к потере побочных эффектов, описанных в документации.
Смысл подобной пунктуальности, опять же, состоит в том, чтобы облегчить использование данного класса. Разработчик, который будет пользоваться этим классом, как представляется, чаще будет заглядывать в справочное руководство по конкретной, интересующей его, функции API, нежели в файл, содержащий описание класса. И он вправе ожидать похожего, если не идентичного, поведения от функций-обёрток.
Комментарии (3)
[...] организации логирования разработчик видит все исключения, все непредвиденные обстоятельства, с которыми [...]
OpenQuality.ru | Качество программного обеспечения | 21.02.2011 | 11:51 дп. #
В конце статьи:
>>Продолжение следует…
Оно, продолжение, уже существует? Очень интересный и полезный материал, хочется все-таки продолжения!
Dmitry | 18.02.2012 | 1:04 пп. #
Дмитрий,
Спасибо за добрые слова в наш адрес. Каркас продолжения существует, но потребуется какое-то время, чтобы облечь его в форму статьи.
Кормчий | 20.02.2012 | 11:21 дп. #