Автоматическое управление временем жизни объектов в Delphi

Garbage Collector для Delphi? Да! Но с некоторыми ограничениями.

Предисловие

Из-за необходимости создать приложение под Android мне пришлось писать на Java. Обычно я пишу на Delphi, причем довольно старом (зато лицензионном). И одно из отличий в написании кода на этих языках станет предметом этой статьи.

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

Class1 Ojb1 = new Class1();
Class2 Obj2 = new Class2();
...
работа с объектами
...

Можно полностью сосредоточиться на логике программы, алгоритмах.
В Delphi приходится писать намного больше:

Obj1 := TClass1.Create;
try
  Obj2 := TClass2.Create;
  try
    ...
    работа с объектами
    ...
  finally
    FreeAndNil( Obj2 ); // ну или просто Obj2.Free;
  end;
finally
  FreeAndNil( Obj1 ); // ну или просто Obj1.Free;
end;

А если объектов 3 и более? Вложенных друг в друга try finally становится слишком много. Да, можно писать без вложенных try finally, например так:

Obj1 := TClass1.Create;
Obj2 := TClass2.Create;
try
  ...
  работа с объектами
  ...
finally
  FreeAndNil( Obj1 ); 
  FreeAndNil( Obj2 ); 
end;

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

Иногда пишут так:

Obj1 := nil;
Obj2 := nil;
try
  Obj1 := TClass1.Create;
  Obj2 := TClass2.Create;
  ...
  работа с объектами
  ...
finally
  FreeAndNil( Obj1 );
  FreeAndNil( Obj2 );
end;

Код «вырос» в высоту за счет строчек obj := nil. И не избавил от возможной утечки памяти при возникновении исключения в деструкторе первого объекта.

И в любом из этих случаев получается заметно больше кода. Больше кода — значит дольше писать, больше шансов ошибиться, тяжелее читать.

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

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

Но самая неприятная ситуация, когда уничтожил объект, а его оказывается кто-то использует. Здравствуй, Access Violation (AV). Ошибка может проявляться не всегда, а только при определенных действиях пользователя. Такие ситуации отследить сложно.

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

Методы автоматического управления памятью в Delphi

Размещение в стэке

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

Механизм с Owner

Один объект владеет другим. И при удалении владельца уничтожаются и все объекты, которыми он владеет. В Delphi это использовано для потомков TComponent. Т.е. TApplication, TForm, потомки TControl и др.

Схематично:

Application -> Form1 -> Component1
                     -> Сontrol1
            -> Form2 -> Control1
                     -> Control2

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

Можно ли использовать такой подход где-то еще? На свой страх и риск. А риск в том, что кто-то использует объект, который владелец, не задумываясь, уничтожает при собственном уничтожении. В известном C++ фреймворке Qt такой механизм, впрочем, используется.

RefCounting или подсчет ссылок

Во-первых этот подход изумительно работает для строк (string). Этот механизм позволяет следить, когда память под строку можно освобождать, когда можно передать просто указатель на строку, а когда нужно ее скопировать.

Во-вторых подсчет ссылок используется в интерфейсах (интерфейсные ссылки, IInterface). Компилятор сам добавляет необходимые команды: для инициализации интерфейсной ссылки nil-ом, вызов AddRef при присваивании другой переменной, Release при обнулении или выходе переменной из области видимости.

Работа с интерфейсными ссылками:

var
  Obj1, Obj2: IMyInterface;
begin
  Obj1 := TClass1.Create;
  Obj2 := TClass2.Create;
  ...
  работа с объектами
  ...
end;

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

Циклические ссылки — это когда объекты ссылаются друг на друга.

Циклические ссылки встречаются довольно часто. Например, отношение Parent — Child в каких-нибудь списках, Publisher — Subscriber и т.д. Бывают и более сложные цепочки — первый объект ссылается на второй, второй на третий, третий на первый и т.д.

В таких случаях счетчик ссылок никогда не станет равным нулю и объекты «зависнут» в памяти. И если специально не разрывать циклические ссылки в коде — это утечка памяти.

Зато никогда не будет «висячих» указателей (на уже уничтоженные объекты).

Идем дальше

Это то, что уже есть в Delphi, «из коробки» как говорится. Использование интерфейсов дает почти нужный результат. Нужно только разобраться с циклическими ссылками. Один из подходов давно известен — Weak Ref — слабые ссылки. Это указатель, который не влияет на счетчик ссылок.

В самых новых версиях Delphi (начиная с 10.1) есть встроенные weak ref для интерфейсных ссылок (есть и для обычных объектов, но работает только под мобильные ОС).

Пример такой ссылки:

[weak] weak_ref: IMyInterface;

И использование такой ссылки в коде:

var
  strong_ref: IMyInterface
begin
  strong_ref := weak_ref; // слабая ссылка преобразуется в сильную, 
                          // увеличивая счетчик ссылок
  if Assigned( strong_ref ) then 
  ... // использование объекта

И этот способ работает почти идеально. Почему почти?

Во-первых есть возможность в проекте (особенно сложном) случайно сделать циклическую ссылку из сильных ссылок. Раз разделение на сильные и слабые ссылки делает человек, значит есть «человеческий фактор». Кто-нибудь что-нибудь рано или поздно обязательно сделает неправильно.

Во-вторых нужно писать дополнительный код:

  1. объявление дополнительной локальной переменной
  2. получение сильной ссылки из слабой
  3. и обязательно проверить полученную ссылку на nil.

Т. е. довольно много дополнительного кода.

Сравним способы использования объектов

Способ использования объектов Объем кода Безопасность
1 Обычные объекты много (вложенные try finally) небезопасно (ссылки на уничтоженные и используемые объекты не отслеживаются)
2 Интерфейсные ссылки в чистом виде мало небезопасно (утечка памяти из-за циклических ссылок)
3 Интерфейсы, сочетающие сильные и слабые ссылки много (приведение слабой ссылки к сильной и т.д.) почти безопасно (возможны циклические ссылки из-за человеческого фактора)
4 ? мало безопасно

Что скрывается под номером 4? Garbage Collector!
Создание сборщика мусора для Delphi вынесено в отдельную статью.

Garbage Collector для Delphi

Оставьте комментарий