Техническая статья

Исключения в конструкторах Delphi и nil-safe деструкторы

Когда Delphi-конструктор поднимает исключение, RTL немедленно вызывает деструктор на полусобранном экземпляре, поэтому каждый деструктор обязан справляться с полями, которые так и не были присвоены. HotXLS применяет это правило к своим деструкторам, включая TXLSXRichText и TXLSXCells: провал выделения внутри таких конструкторов доезжает до вашего кода как исходный EOutOfMemory, а не как вторичный access violation, поднятый при уборке

Класс бага старый и легко пропускаемый. Сервис конвертирует книгу на 300 МБ на нагруженном сервере, память кончается, и в логе — access violation внутри деструктора. Ничто в этом сообщении не говорит «кончилась память», ничто не подсказывает, что повторная попытка позже сработала бы, а стек указывает на деструктор, выглядящий безупречно правильно. Исходное исключение существовало несколько микросекунд и было заменено. Эта статья разбирает, почему так происходит, как HotXLS устроил деструкторы, чтобы этого избегать, и как писать собственные классы, чтобы они так не делали

Что происходит, когда Delphi-конструктор падает?

Объект уничтожен за вас до того, как исключение дойдёт до вызывающего, а ваш деструктор выполняется над тем состоянием, которого конструктор достиг. Последовательность фиксирована компилятором и RTL. NewInstance выделяет экземпляр, InitInstance заполняет его нулями, поэтому каждая объектная ссылка стартует как nil, каждый целый — 0, каждая строка — пустой. Затем тело конструктора выполняется внутри неявного exception-хендлера. Если что-то в теле падает, сгенерированный код вызывает виртуальный деструктор: исполняется BeforeDestruction, затем ваш Destroy, затем FreeInstance отпускает память, и в конце исключение перебрасывается. AfterConstruction не выполняется никогда, а переменная слева от X := TFoo.Create так и не присваивается

Жизненный цикл объекта Delphi: RTL вызывает Destroy на полусобранном экземпляре HotXLS, когда конструктор падает, до того как исходное исключение дойдёт до вызывающего
когда Delphi-конструктор падает, RTL сперва уничтожает полусобранный экземпляр и лишь затем перебрасывает исходное исключение, поэтому Destroy исполняется над тем состоянием, которого сборка достигла

Итак, деструктор видит объект, остановившийся на произвольной строке своего конструктора. Поля, присвоенные до провала, держат настоящие объекты. Поля после него — всё ещё nil. Деструктор, написанный в предположении, что сборка завершилась, разыменует одно из этих nil-полей и поднимет собственный access violation

Вот второе исключение и есть настоящий ущерб. Delphi не выстраивает в цепочку исключения, поднятые при этой уборке: access violation заменяет EOutOfMemory, исходное исключение выбрасывается, и вызывающий видит только AV. Полезный диагноз — «не хватило выделения, попробуйте снова с большей памятью» — исчезает, а вместо него нечто, похожее на порчу памяти

Откуда берётся вторичный access violation?

Он приходит от деструктора, предполагающего завершённость конструктора и трогающего поле-список, которое равно nil, когда падает самое первое внутреннее выделение. Два класса HotXLS показывают форму наглядно. И автономный TXLSXRichText.Create, и перегрузка под комментарием TXLSXRichText.Create(AOwner: TXLSXComment) вызывают inherited Create, затем выделяют FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) делает то же с FList := TList.Create прежде, чем строить таблицы упакованных строк и упакованное хранилище ячеек. В Lib/lxHandleX.pas оба деструктора стерегут эти поля:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // конструктор мог остановиться до FRuns
  for i:= 0 to FRuns.Count- 1 do
    TXLSXRichTextRun(FRuns[i]).Free;
  FRuns.Clear;
end;

destructor TXLSXCells.Destroy;
begin
  FPackedStorageEnabled:= False;
  if FList<>nil then Clear;          // FList равен nil, если упал TList.Create
  // ... упакованное хранилище, таблицы строк и FList отпускаются через Free
  inherited Destroy;
end;

Точка провала точна. Считая выделения из кучи при сборке, первое — сам экземпляр; если падает NewInstance, тело конструктора не исполняется и деструктор не вызывается, поэтому тот случай всегда чист. Второе выделение — TList. Когда оно падает, FRuns или FList остаётся nil, RTL вызывает Destroy, и незастережённый Destroy читает Count через nil-ссылку — ровно тот самый вторичный access violation. Более поздние провалы внутри TXLSXCells.Create безвредны: раз FList существует, Clear проверяет FPackedStorage <> nil перед использованием упакованного хранилища

Карта порядка выделений в конструкторах HotXLS на Delphi: упавший NewInstance чист, упавший TList.Create оставляет поле nil, поздние провалы застережены
существенно только второе выделение: провал TList оставляет поле nil, и именно незастережённый Destroy превращает EOutOfMemory в access violation

Эти классы существенны, потому что создаются валом. Каждая загруженная таблица строит коллекцию TXLSXCells, каждая ячейка или комментарий с форматированными прогонами — TXLSXRichText. Большая книга с насыщенным rich text создаёт их тысячи, поэтому под давлением памяти выделение, падающее последним, имеет реальные шансы приземлиться на одной из этих строк

Free безопасен при nil, доступ к полю — нет

TObject.Free проверяет Self <> nil перед вызовом Destroy, поэтому FRuns.Free на nil-поле — пустая операция. Чтение FRuns.Count — нет: Count читает поле по смещению от указателя экземпляра, а виртуальный вызов вроде FRuns.Clear сперва читает указатель VMT из экземпляра. Оба падают на nil. Эта асимметрия и есть вся ловушка. Вызовы освобождения в типичном деструкторе уже безопасны; беда живёт в циклах и чистящих вызовах, поставленных перед ними. Вот паттерн в маленьком классе вашего собственного:

type
  TStyledText = class
  private
    FRuns: TList;
    FIndex: TStringList;
  public
    constructor Create;
    destructor Destroy; override;
  end;

constructor TStyledText.Create;
begin
  inherited Create;
  FRuns := TList.Create;          // EOutOfMemory здесь оставляет FRuns = nil
  FIndex := TStringList.Create;   // ... а здесь оставляет FIndex = nil
end;

destructor TStyledText.Destroy;   // ПЛОХО: предполагает завершённый конструктор
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // access violation при FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

Исправленный деструктор застерегает каждое разыменование и отпускает поля только nil-безопасными вызовами:

destructor TStyledText.Destroy;
var
  I: Integer;
begin
  if FRuns <> nil then
    for I := 0 to FRuns.Count - 1 do
      TObject(FRuns[I]).Free;
  FreeAndNil(FIndex);
  FreeAndNil(FRuns);
  inherited Destroy;
end;

Правила, вытекающие из этого, коротки настолько, что годятся для код-ревью:

  • Каждое использование владеемого поля в деструкторе, кроме Free или FreeAndNil, требует проверки на nil или Assigned — включая вызовы в хелперы вроде Clear или ClearCore, итерирующие поле
  • Отпускайте владеемые объекты через Free или FreeAndNil, никогда прямым вызовом Destroy, который пропускает тест на nil
  • Держите inherited Destroy последним оператором, чтобы предок разбирал свои поля после ваших
  • Не рассуждайте от порядка полей в конструкторе: следующий мейнтейнер вставит выделение выше вашего и молча сдвинет точку провала

Где живёт inherited Create

Вызывайте inherited Create первым — как делают оба конструктора HotXLS. Тогда поля предка существуют до запуска вашего кода, и зеркальное отображение в деструкторе, inherited Destroy последним, разворачивается в обратном порядке. Если конструктор предка сам падает, ваш деструктор всё равно выполняется со всеми вашими полями в nil, и nil-safe деструктор обрабатывает это без особых случаев. Явные присваивания вроде FOwner := nil в конструкторе избыточны после InitInstance, но безвредны и иногда стоят того как документация

Не ловите и не освобождайте внутри конструктора

Два благонамеренных паттерна ухудшают дело. Первый — оборачивание тела конструктора в try ... except Free; raise; end: RTL и так собирается вызвать деструктор, поэтому объект уничтожается дважды. Второй — глотание исключения ради «продолжить работу»: вызывающий получает правдоподобную ссылку на объект с nil-полями, а провал переезжает в какой-нибудь неродственный вызов метода сильно позже. Если конструктор создаёт временный объект, который не хранит, освобождайте его в локальном try ... finally и позволяйте исключению уходить наверх нетронутым

Как тестировать провальные пути конструктора?

Валите каждую точку выделения в конструкторе нарочно, по одной за раз, требуя EOutOfMemory каждый раз, а после него — успешную нормальную сборку. Инструмент для этого — замещающий менеджер памяти, чьи GetMem, AllocMem и ReallocMem считают запросы положительного размера и возвращают nil на выбранном порядковом. Сперва посчитайте выделения, которые делает конструктор на вашей целевой сборке, затем гоняйте по одному впрыснутому провалу на порядковый

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

Числа выделений принадлежат одному компилятору и платформе; другой компилятор или RTL может выделять иначе, поэтому меряйте на сборке, которую отгружаете. HotXLS применяет то же nil-safe правило по всей библиотеке — от коллекций стилей, диаграмм и pivot до хеш-таблиц, zip-писателя и PDF-экспортёра. Хеш-таблицы заслуживают отдельной пометки: таблица, сменившая маску до перераспределения, которое затем упало, оставляет деструктор, шагающий по памяти за старой таблицей, поэтому маска должна меняться только после того, как новое хранилище существует. Один случай сидит в самом RTL: TInterfaceList падает в деструкторе при провале внутреннего выделения. Ничто из этого не является rollback'ом произвольной операции, упавшей на полпути, и не даёт общей гарантии восстановления после нехватки памяти

Что это меняет для кода, открывающего большие книги?

На практике EOutOfMemory с этих путей сборки — сигнал, который можно поймать и обработать, а не access violation, который приходится считать крахом. TXLSXWorkbook.Create не принимает параметров, а TXLSXWorkbook.Open(const FileName: WideString): Integer возвращает 1 при успехе и -1 при провале. Исключения при загрузке записываются в коллекцию Diagnostics книги как xlsDiagnosticUnhandledException и затем перебрасываются — поведение, описанное в статье о структурных диагностиках HotXLS. Обёртка повторных попыток выглядит так:

type
  EWorkbookOpenFailed = class(Exception);

function OpenWorkbookWithRetry(const FileName: WideString;
  MaxAttempts: Integer): TXLSXWorkbook;
var
  Attempt: Integer;
  Book: TXLSXWorkbook;
begin
  Result := nil;
  for Attempt := 1 to MaxAttempts do
  begin
    Book := nil;
    try
      try
        Book := TXLSXWorkbook.Create;          // без аргументов конструктора
        if Book.Open(FileName) <> 1 then        // 1 = успех, -1 = провал
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // владение уходит вызывающему
        Exit;
      finally
        Book.Free;                             // nil-safe после передачи
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // ваш код: сбросить кэши, другие книги
      end;
    end;
  end;
end;

Несколько деталей обёртки намеренные. Каждая попытка строит свежий TXLSXWorkbook: nil-safe деструкторы делают провалы сборки чистыми, но не делают наполовину выполненный Open возобновляемым, поэтому неудавшийся экземпляр просто освобождается. Перезапускается только EOutOfMemory; EWorkbookOpenFailed, ошибки пароля и всё прочее уходят наверх с первой же попытки. EAccessViolation не стоит перезапускать вообще — он сигнализирует дефект, а не нехватку ресурса. И перезапуск имеет смысл, только если между попытками что-то меняется, поэтому хендлер сперва освобождает память. Нужен диагностический след неудавшейся попытки — читайте Book.Diagnostics до того, как блок finally её освободит

Матрица решений о повторных попытках в Delphi с HotXLS: EOutOfMemory перезапускается со свежим TXLSXWorkbook, прочие провалы уходят сразу, access violation не перезапускается никогда
каждая попытка строит свежую книгу, потому что наполовину выполненный Open не возобновить, и только EOutOfMemory заслуживает вторую попытку

Перезапуск — смягчение, а не проект мощности. 32-битный процесс, регулярно ловящий EOutOfMemory на больших файлах, обычно сидит без непрерывного адресного пространства, а не физической памяти, и прочный ответ — сборка Win64. То же мышление применимо на стороне записи, где crash-safe сохранения HotXLS с поэтапными временными файлами не дают неудавшемуся сохранению испортить существующий файл. Если несколько потоков делят одну книгу, read lease и write guard HotXLS описывают, как координируется доступ к тому экземпляру

Чек-лист для ваших собственных Delphi-классов

Сами застережения по одной строке каждое, и тот же аудит занимает минуты на вашем коде. Ищите деструкторы, вызывающие метод Clear, Reset или ClearCore до освобождения полей, и проверяйте, не трогает ли тот метод поле, до которого конструктор мог не добраться. Ищите циклы по владеемым спискам без предшествующей проверки на nil. Ищите конструкторы, ловящие исключения, и убирайте и глотание, и ручной Free. Хотите доказательство, а не осмотр — считающий менеджер памяти из описания выше помещается на страницу кода и превращает каждое выделение в конструкторе в тестовый случай

Полный набор возможностей, поддерживаемые версии Delphi и C++Builder и лицензирование — на странице продукта HotXLS Delphi Excel component