Жизненный цикл объектов в Delphi. Часть 1. Windows, OSX. Что же использовать Destroy, Free, FreeAndNil или DisposeOf?

native_iOS_SpinBox
С появлением мобильных платформ в мире Delphi, произошли серьезные изменения в жизненном цикле объектов. Послужившие причиной многих проблем и вопросов, а как правильно кроссплатформенно удалять объекты. В этой статье детально рассматриваем жизненный цикл объектов на разных платформах и даём ответы на важные вопросы, которые могут побеспокоить даже опытных Delphi разработчиков.


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

Жизненный цикл объектов настольных платформ Windows и OSX

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

type

  TMyObject = class(TObject)
  public
    constructor Create;
    destructor Destroy; override;
    class function NewInstance: TObject; override;
    procedure FreeInstance; override;

    procedure AfterConstruction; override;
    procedure BeforeDestruction; override;

    procedure SomeAction;
  end;

{ TMyObject }

procedure TMyObject.AfterConstruction;
begin
  Writeln('TMyObject.AfterConstruction');
  inherited;
end;

procedure TMyObject.BeforeDestruction;
begin
  Writeln('TMyObject.BeforeDestruction');
  inherited;
end;

constructor TMyObject.Create;
begin
  Writeln('TMyObject.Create');
end;

destructor TMyObject.Destroy;
begin
  Writeln('TMyObject.Destroy');
  inherited;
end;

procedure TMyObject.FreeInstance;
begin
  inherited;
  Writeln('TMyObject.FreeInstance');
end;

class function TMyObject.NewInstance: TObject;
begin
  Writeln('TMyObject.NewInstance');
  Result := inherited;
end;

procedure TMyObject.SomeAction;
begin
  Writeln('TMyObject.SomeAction');
end;

Жизненный цикл любого объекта можно описать следующими шагами:

  1. Фаза создания.
  2. Фаза работы с объектом.
  3. Фаза уничтожения.

Фаза 1. Создание

В результате этой фазы создается новый объект. Чтобы создать экземпляр любого класса нужно вызвать конструктор System.TObject.Create.

var
  MyObject: TMyObject;
begin
  MyObject := TMyObject.Create;

Фаза создания любого объекта условно состоит из трех шагов:

  1. Выделение оперативной памяти под новый объект. Что равносильно вызову классового виртуального метода System.TObject.NewInstance, задачей которой служит выделение памяти под создаваемый объект и возврат указателя на выделенную память. Затем этот указатель передается в метод инициализации.
  2. Первичная инициализация памяти нулями и инициализация таблицы виртуальных методов объекта. Этот шаг сопровождается вызовом классового метода System.TObject.InitInstance.
  3. Вторичная инициализация нашими данными. Равносильно выполнению кода конструктора TMyObject.Create.

Не смотря на то, что объект мы создаем одной строчкой кода, шагов у нас 3,

MyObject := TMyObject.Create;

Компилятор неявно выполняет эти шаги за нас. При компиляции компилятор неявно вставляет в начало вызова конструктора вызов системного метода System.ClassCreate, который приводит к вызову метода System.TObject.NewInstance. Этот метод как раз и выделяет память под новый объект. Обратите внимание, что память выделяется из кучи и содержит мусор.

class function TObject.NewInstance: TObject;
begin
  Result := InitInstance(_GetMem(InstanceSize)); // <-- выделение и инициализация памяти
{$IFDEF AUTOREFCOUNT}
  Result.FRefCount := 1;
{$ENDIF}
end;

После этого внутри метода System.TObject.NewInstance происходит первичная инициализация выделенной памяти путем вызова метода System.TObject.InitInstance. Задача этого классового метода — это первичное заполнение выделенной памяти с мусором нулями и инициализация таблицы виртуальных методов.

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

Теперь давайте посмотрим все это на практике. Для этого поставим точку останова на первой строчке нашего конструктора:

Запустим программу и перейдем в окно вывода процессорных команд.

Этот пункт меню открывает специальное окно с выводом процессорных инструкций и блоков с соответствующим исходным кодом. Обратите внимание на команды, выделенные на скриншоте ниже:

Текущая точка выполнения команды стоит на начале конструктора.  Дальше для нас важна строчка с вызовом ClassCreate:

call @ClassCreate

Именно она внутри и вызовет наш метод NewInstance. На скришоте ниже представлен код функции ClassCreate.  Который явно вызывает System.TObject.NewInstance.

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

После выхода из конструктора компилятор опять же вставляет неявно вызов виртуального метода TObject.AfterConstruction. Который предназначен для выполнения пользовательских действий после выполнения тела конструктора. В нашем первоначальном примере мы перекрыли этот метод и в нем только выводим на экран название этого метода.

Теперь если в этот момент посмотреть в лог, то мы увидим еще одно подтверждение этих вызовов, кроме вызова System.TObject.InitInstance. (Этот метод не является виртуальным и не предназначен для переопределения).

Зачем нужен NewInstance?

Этот вопрос нужно рассматривать не с точки зрения наличия такого метода, а с точки зрения его виртуальности. Мы уже выяснили, что он выделяет и первоначально инициализирует память. А зачем нам, как программистам, разрешено его перекрывать?

Я знаю два варианта использования этой возможности. Если вы знаете еще варианты применения, то поделитесь ими в комментариях:

  1. Создание синглтона
  2. Ускорение выделение памяти при частом и массовом создании объектов.
Singleton

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

var
  A1, A2: TSingletonObject;
begin
  A1 := TSingletonObject.Create;
  A2 := TSingletonObject.Create;
  writeln('Объекты A1 и A2 равны? ' + BoolToStr(A1 = A2, True));
end;

В примере выше TSingletonObject класс, реализация которого представлена ниже:

type

  TSingletonObject = class
  private 
    class var Instance: TSingletonObject;
  public
    class function NewInstance: TObject; override;
  end;
  
class function TSingletonObject.NewInstance: TObject;
begin
  if Instance = nil then
    Instance := TSingletonObject(inherited NewInstance);
  Result := Instance;
end;

Идея простая, мы переопределяем метод выделения памяти System.TObject.NewInstance и возвращаем в результате уже готовый объект, вместо того, чтобы заново выделять память. Instance — это классовое поле, то есть поле, привязанное к классу и имеющее одинаковое значение для всех экземпляров. В нем мы храним тот единственный экземпляр. Сколько бы раз вы не пытались создать объект, каждый раз вы будите получать один тот же объект. Отсюда вытекает главный недостаток: Не возможность централизованно контролировать жизнь объекта. Неизвестно кто и когда должен уничтожить этот объект.

После запуска программы в результате в консоль выведется сообщение:

Объекты А1 и А2 равны? True
Ускорение выделение памяти

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

Идея следующая:

  1. Выделяем заранее большой блок памяти.
  2. При каждом создании объекта, в методе System.TObject.NewInstance мы возвращаем указатель на часть выделенного блока памяти, вместо того, чтобы выделять память заново.
  3. При каждом удалении объекта, вместо того, чтобы отдавать память в систему. Мы ее помечаем, как освободившуюся для повторного использования.

Это поможет сэкономить время, но опять же имеет смысл только реально, когда в этом есть проблема.

Повторный вызов конструктора?

Что будет если повторно вызвать конструктор для объекта?

Перед тем, как дать ответ на этот вопрос, сразу хочу сказать, что этот способ не практикуется и имеет ряд недостатков. Однако, понимать, что происходит иногда бывает полезно. Итак, как мы уже выяснили, при первом вызове конструктора,

MyObject := TMyObject.Create;

происходит выделение и инициализация памяти. При повторном аналогичном вызове, произойдет очередное выделение и инициализация памяти. Однако, если вызвать конструктор для объекта, а не для класса:

MyObject .Create;

произойдет повторная инициализация объекта без выделения памяти. Это хорошо видно в окне просмотра процессорных команд ClassCreate. Вернемся к нему:

Выделенная строчка отвечает за пропуск повторной инициализации объекта. Вместо этого в конце будет опять повторно вызван конструктор. Одна из главных проблем повторного вызова заключается в том, что обычно в конструкторе создаются экземпляры других классов. При повторном вызове уже созданные экземпляры не удаляются, так как деструктор для объекта не вызывается перед повторным вызовом, что и правильно. Это приводит к утечкам памяти. Поэтому, не смотря на то, что вы можете повторно вызвать конструктор для объекта, делать этого не стоит.

Фаза 2. Работа с объектом

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

Фаза 3. Удаление

Данная фаза очень похожа на первую и по сути является полной противоположностью фазы создания.

Чтобы удалить объект можно воспользоваться одним из следующих способов.

// Способ 1
MyObject.Destroy;

// Способ 2
MyObject.Free;

// Способ 3
FreeAndNil(MyObject);

Удаление объекта проводится в два шага:

  1. Шаг выполнения пользовательского кода деструктора для выполнения деинициализации. Выполнение кода System.TObject.Destroy.
  2. Шаг очистки памяти, занимаемой объектом. Та память, которая была выделена в System.TObject.NewInstance, теперь обратно возвращается в систему, в кучу. Для этого у объекта есть специальный виртуальный метод System.TObject.FreeInstance.

Как вы уже могли догадаться, компилятор опять за нас вставляет вызовы служебных методов, которые позволяют выполнить деиницализацию и очистку памяти.

Посмотрим теперь на это вживую. Поставьте точку останова на начало кода деструктора:

Теперь запустим приложение с отладкой и в момент остановки откроем окно процессорных команд:

Давайте разберемся с командами по очереди. Выполнение деструктора начинается с вызова метода System.TObject.BeforeDestruction. Этот метод предназначен для уведомления программистов, что сейчас начнется процедура уничтожения объекта. Программист может перекрыть этот метод и воспользоваться им.

Затем идет выполнение пользовательской деинициализации. Другими словами просто выполнение нашего кода деструктора, в котором мы освобождаем те ресурсы и объекты, которые создали в нашем конструкторе.

Финальная заключительная фаза — это вызов служебной процедуры ClassDestroy.

procedure _ClassDestroy(const Instance: TObject);
begin
  Instance.FreeInstance;
end;

Как видите код простой. Он вызывает метод объекта, который освобождает закрепленную за объектом память.

Зачем нужен FreeInstance?

Это виртуальный метод, поэтому мы можем переопределить его. Это имеет смысл только в том, случае если мы сами отвечаем за выделение памяти под наш объект. И пишем свою модель распределения памяти. Смотрите раздел выше про NewInstance. В этом случае в этом методе мы можем не отдавать память в системную кучу, а пометить эту память, как свободную в нашем импровизированном менеджере памяти.

Теперь давайте рассмотрим, какой же способ из трех по-моему являются хорошим.

Использование Destroy

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

Можно добавить дополнительную проверку перед удалением объекта, и проверить, что указатель на объект не нулевой.

if MyObject <> nil then
  MyObject.Destroy;

Однако, эта проверка не спасет нас от следующей надуманной ситуации:

var
  MyObject1, MyObject2: TMyObject;
begin
  MyObject1 := TMyObject.Create;
  MyObject2 := MyObject1;

  MyObject2.Destroy;
  if MyObject1 <> nil then // <-- условие не корректное, так как указатель на объект MyObject2 остался
    MyObject1.Destroy; // <-- Ошибка, так как объекты был уже удален

Мы создаем один объект и присваиваем его в другую переменную. После этого мы удаляем объект по второй переменной MyObject2. При этом указатель в MyObject1 продолжает указывать на область памяти, которая уже была освобождена при удалении MyObject2. И наша проверка проходит, однако повторное удаление закончится опять же ошибкой.

Использование Free

На ряду с прямым вызовом деструктора TObject.Destroy. Есть способ с использованием метода TObject.Free.

procedure TObject.Free;
begin
// under ARC, this method isn't actually called since the compiler translates
// the call to be a mere nil assignment to the instance variable, which then calls _InstClear
{$IFNDEF AUTOREFCOUNT}
  if Self <> nil then
    Destroy;
{$ENDIF}
end;

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

На текущий момент не обращайте внимание на директиву условной компиляции, мы рассмотрим этот случай в следующей части статьи.

Как вы понимаете, этот метод так же не спасает от ситуации мусорного указателя (смотрите описание Destroy).

Хочу обратить ваше внимание на следующий кусочек кода:

  if MyObject1 <> nil then
    MyObject1.Free;

Как вы понимаете, метод Free уже делает проверку на nil, поэтому первое условие является лишним и не нужным. Достаточно оставить только:

MyObject1.Free;

Эту ошибку делают многие программисты и добавляют дополнительную проверку.

Использование FreeAndNil

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

FreeAndNil — это обычная процедура, объявленная в модуле System.SysUtils.

procedure FreeAndNil(var Obj);
{$IF not Defined(AUTOREFCOUNT)}
var
  Temp: TObject;
begin
  Temp := TObject(Obj);
  Pointer(Obj) := nil;
  Temp.Free;
end;
{$ELSE}
begin
  TObject(Obj) := nil;
end;
{$ENDIF}

На текущий момент не обращайте внимание на директиву условной компиляции, мы рассмотрим этот случай в следующей части статьи.

Эта процедура заниливает указатель на объект и вызывает у него метод Free.

Перед тем, как я поясню, почему этот способ лучше, рассмотрим следующий пример:

var
  MyObject1, MyObject2: TMyObject;
begin
  MyObject1 := TMyObject.Create;
  MyObject1.Destroy;

  if MyObject1 <> nil then
    MyObject1.Destroy;

Как мы уже выяснили, в этом коде произойдет исключение AV при попытке вызвать второй раз деструктор. В нашем примере ошибка сработает только в момент, когда будет освобождаться память. То есть в методе TObject.FreeInstance, а не в деструкторе.

Теперь небольшое изменение в коде. Перед повторным удалением MyObject1 добавляем создание другого объекта.

var
  MyObject1, MyObject2: TMyObject;
begin
  MyObject1 := TMyObject.Create;
  MyObject1.Destroy;

  MyObject2 := TMyObject.Create;

  if MyObject1 <> nil then
    MyObject1.Destroy;

Теперь, если вы запустите этот код под Windows 32, вы будите удивлены. Ошибки не будет. Код отработает так, как будто все нормально. Но на самом деле проблема стала намного глубже. Давайте разберемся.

var
  MyObject1, MyObject2: TMyObject;
begin
  // Создали новый объект. в MyObject1 находится указатель на память
  MyObject1 := TMyObject.Create;
  // Объект уничтожили, память освободили. НО MyObject1 продолжает находится указатель на освобожденную память.
  MyObject1.Destroy;

  // Выделяем новую память. Менеджер памяти делфи выделит новую память под объект MyObject2. Но эта память будет та же самая, что была у MyObject1.
  MyObject2 := TMyObject.Create;
 
  if MyObject1 <> nil then
    // Повторно удаляем объект и освобождаем память, которая уже отдана под MyObject2.
    MyObject1.Destroy;

Как вы понимаете, при повторном удалении MyObject1, мы испортили и освободили память, отведенную под объект MyObject2. Тем самым мы заложили мину замедленного действия. Проблема с Acess Violation всплывет в будущем при использовании MyObject2.

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

Итог

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

При этом лучшим способом удаления объектов является использование утилитной процедуры System.SysUtils.FreeAndNil.

На этом наше знакомство с жизненным циклом не заканчивается, так как появление мобильных платформ в Delphi кардинально поменяло жизненный цикл объектов на этих платформах и ввело такое понятие, как ARC (автоматический подсчет ссылок). При ARC объект реально удаляется не тогда, когда пользователь вызывает деструктор, а когда объект больше никто не использует. При ARC каждый объект «считается». Другими словами любое использование объекта приводит к инкременту счетчика ссылок на него. Любое освобождение ссылки — обнуление указателя, приводит к декременту счетчика.

Тема подсчета ссылок вводит проблемы для программистов с одной стороны, а с другой стороны стимулирует программиста яснее выражать свои мысли.

Детально об этом мы поговорим в следующей части статьи.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *