Friday, September 29, 2006

Из жизни исключений

Вот некоторые интересные факты из жизни исключений (exceptions) в языке Delphi Pascal. Статья подразумевает, что читатель знает, что такое exception и как с ними работают. Здесь освещены некоторые любопытные и менее очевидные моменты.

1. Исключения в конструкторе

Если в конструкторе любого объекта произойдет исключение, будет автоматически вызван деструктор этого объекта. Это обеспечивается "магией" компилятора. Отсюда следует, что правильной идиомой для создания и уничтожения класса является:
Obj = TSomeClass.Create;
try
  // ... работа с Obj
finally
  Obj.Free;
end;
Т.о. если исключение произойдет в конструкторе, деструктор будет вызван автоматически, а если после завершения конструктора - то деструктор будет вызван через Free из блока finally.

Строго говоря, не является ошибочным и следующий вариант:

Obj = nil;
try
  Obj = TSomeClass.Create;
  // ... работа с Obj
finally
  Obj.Free;
end;
Однако, такой способ некрасив и избыточен (лишнее присвоение Obj = nil). К тому же, помещение конструктора в блок try-finally само по себе вовсе не помогает против исключения в конструкторе. В самом деле, посмотрим внимательнее на строчку "Obj = TSomeClass.Create;". Здесь сначала выполняется конструктор, а потом его результат присваевается переменной Obj. Если в конструкторе произойдет исключение, то до присвоения переменной дело просто не дойдет. Поэтому в блоке finally переменная Obj по-прежнему будет nil. Так что только автоматический вызов деструктора может предотвратить потерю ресурсов при исключении в конструкторе. Вероятно поэтому создатели Delphi его и сделали. Сложно предложить другой способ.

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

type
  TExample = class
  private
    FList : TList;
    // ...
  public
    constructor Create;
    destructor Destroy; override;
    // ...
  end;

constructor TExample.Create;
begin
  inherited;
  // ... иницилизация других private-полей
  FList := TList.Create;
end;

destructor TExample.Destroy;
var
  I : Integer;
begin
  for I := 0 to FList.Count - 1 do
    // что-то сделать с FList[I]
  FList.Free;
  inherited;
end;
Деструктор этого класса содержит потенциальную опасность. Если исключение произойдет в конструкторе до того, как FList был создан, то деструктор будет вызван автоматически, и при этом FList будет все еще nil. Т.о. обращение FList.Count вызовет access violation, которе "замаскирует" изначальное исключение, так что сложно будет понять, что именно случилось не так. Правильнее было написать:
destructor TExample.Destroy;
var
  I : Integer;
begin
  if FList <> nil then begin
    for I := 0 to FList.Count - 1 do
      // что-то сделать с FList[I]
    FList.Free;
  end;
  inherited;
end;

AfterConstruction и BeforeDestruction

Теперь вспомним, что все классы в Delphi имеют два унаследованных от TObject "магических" метода AfterConstruction и BeforeDestruction. Первый из них вызывается сразу после конструктора, второй - непосредственно перед деструктором. Интересно отметить, что если исключение произойдет в AfterConstruction, деструктор будет вызван автоматически точно также, как если бы исключение произошло бы в конструкторе. Еще более интересно то, что при возникновении исключения в конструкторе ни AfterConstruction ни BeforeDestruction не вызываются (при возникновении исключения в AfterConstruction метод BeforeDestruction тоже не вызывается). Можно только догадываться, почему Delphi ведет себя именно так, но, по-видимому, вызывать AfterConstruction в таком случае представлялось разработчикам Delphi нелогичным - раз "Construction" не завершено, то и "AfterConstruction" быть не должно. А вызов BeforeDestruction при том, что AfterConstruction не сработал, привел бы к ряду проблем. Например, для классов, унаследованных от TInterfacedObject. Дело в том, что InterfacedObject.InitInstance устанавливает счетчик ссылок RefCount а 1, а TInterfacedObject.AfterConstruction уменьшает RefCount на 1. Делается это для того, чтобы в процессе работы конструктора объект вдруг не уничтожился, это если RefCount увеличится, а потом уменьшится до 0 (В help'e пишут по этому поводу: "When an instance of TInterfacedObject is allocated, RefCount is incremented to prevent its destruction while processing the constructor. AfterConstruction decrements that RefCount to re-establish the correct reference count after all constructors have executed."). Так что, если в конструкторе такого объекта произойдет исключение, то при вызове деструктора RefCount будет равен 1. Если бы в этот момент сработал BeforeDestruction, чей код имеет вид
if RefCount <> 0 then Error(reInvalidPtr);
то мы получили бы run-time error "Invalid pointer operation" вместо "нормального" исключения.

Так или иначе, BeforeDestruction вызван не будет. Помимо упомянутого обхода потенциальной проблемы с TInterfacedObject, это имеет еще такие последствия (которые иногда полезно иметь в виду)

  • Если объект унаследован от TCustomForm или TDataModule, то обработчик события OnDestroy вызван не будет (что логично, т.к. OnCreate тоже не вызывался)
  • Если объект унаследован от TComponent, то при вызове деструктора в ComponentState не будет флага csDestroying

2. Исключения в OnCreate и OnDestroy

Классы, порожденные от TCustomForm и TDataModule имеют события OnCreate и OnDestroy. События эти вызываются из методов DoCreate и DoDestroy этих классов. Эти методы перехватывают любое исключение, которые могло бы произойти в обработчиках этих событий и обрабатывают его с помощью Application.HandleException. Таким образом, если в событии OnCreate вашей формы произойдет исключение, оно будет тут же обработано через Application.HandleException, конструктор не будет прерван, и форма будет создана успешно (т.е. автоматического вызова деструктора, описанного в предыдущем пункте не произойдет). Насколько форма будет при этом функциональна - другой вопрос. Поэтому если при создании формы нужно проделать какую-то критическую операцию - т.е. такую операцию, что при ее неудаче форме вообще нет смысла создаваться - то надо делать ее в конструкторе, а не в событии OnCreate.

3. Исключения в initialization и finalization

Если исключение произойдет в секции initialization или finalization какого-либо из модулей, то оно вообще не будет обработано и программа завершится аварийно с run-time error 217. Казалось бы, исключение должно быть обработано с помощью ExceptProc из SysUtils, но почему-то этого не происходит.

4. Исключения при создании COM-объектов

Исключения при создании COM-объектов связаны с еще большим количеством проблем, чем исключения при создании других объектов. Как известно, если метод COM-объекта объявлен как safecall, то компилятор Delphi неявно "заворачивает" его в блок try-except, причем в блоке except вызывается метод SafeCallException, который гасит исключение, зато обставляет все таким образом, чтобы клиент, вызывающий данный метод, мог восстановить исключение. Таким образом исключение передаются практически "прозрачно" от COM-сервера к его клиенту ("практически", потому что класс исключения у клиента будет уже не тем, что на сервере, а всегда EOleException).

К сожалению, все это не работает при создании COM-объекта. Когда клиент создает COM-сервер (с помощью CreateCOMObject или чего-то аналотичного), COM-объект создается своей фабрикой классов. Если в конструкторе COM-объекта или его методе Initialize произойдет исключение, оно будет перехвачено в методе фабрики классов TComObjectFactory.CreateInstanceLic. При этом, если свойство фабрики классов ShowErrors имеет значение True (а это значение по умолчанию), сервер выдаст MessageBox с текстом ошибки. У клиента же вызов CreateCOMObject завершится маловразумительной ошибкой "Критический сбой" (код E_UNEXPECTED). Все это еще ничего, если сервер является inprocess (DLL) или локальным. Если же сервер вызывается удаленно (через DCOM), то скорей всего он вообще не имеет доступа к экрану серверного компьютера, так что показанный из TComObjectFactory.CreateInstanceLic MessageBox с текстом ошибки не будет виден и нажать на нем "ОК" будет невозможно. В результате сервер намертво зависнет. Даже если серверный процесс имеет доступ к экрану (что будет иметь место, если на сервере Windows 95/98, или же DCOM сконфигурирован соответствующим образом), то все равно сообщение об ошибке на экране сервера мало поможет клиенту. Выводы из этого такие:

  • Если предполагается использование COM-сервера через DCOM, то необходимо выставлять ShowErrors в False у всех фабрик классов.
  • Конструктор и метод Initialize COM-объектов не должны вызывать исключения. Все равно клиент не поймет в чем дело.
Если же при создании COM-объекта необхоодимо проделать какие-либо потенциально опасные (могущие привести к исключению) действия (например, связаться с базой данных), то возможны такие обходные варианты:
  1. Потенциально опасные действия делать в отдельном методе, который клиент вызывает сразу после создания объекта
  2. Обрамить потенциально опасные действия блоком try-except, причем в except погасить исключение и выставить некое свойство "инициализация неуспешна". Клиент в таком случае сразу после создания объекта должен проверить это свойство.
Оба варианта не очень красивы - требуют от клиента лишнего вызова сервера (потенциально по сети). Это отнимает лишнее время, к тому же клиент может просто забыть вызвать дополнительный метод. Но ничего лучше автор предложить пока не может. Призываю читателей этого текста придумать лучшее решение!
Post a Comment