3,3
рейтинг
16 апреля 2014 в 22:30

Разработка → Трюки с интерфейсами в Delphi

Приветствую.
Буквально сегодня обсуждал с коллегой интерфейсы. Он мне рассказал о своем интересном приеме, я ему о своем, но только по дороге домой я осознал всю мощь этих приемов, в особенности если объединить их вместе.
Любители удобной автоматики и MVC паттернов — прошу под кат.

Трюк 1. Умные Weak ссылки


Для тех кто не в курсе — Weak (слабые) ссылки — ссылки, не увеличивающие счетчик. Допустим у нас есть дерево:
INode = interface
  function GetParent: INode;
  function ChildCount: Integer;
  function GetChild(Index: Integer): INode;
end;
Если бы внутри класса, реализующего интерфейс INode родитель и потомки хранились бы так:
TNode = class(TInterfacedObject, INode)
private
  FParent: INode;
  FChild: array of INode;
end;
то дерево бы никогда не уничтожилось. Родитель держит ссылки на детей (и тем самым увеличивает им счетчик), а дети на родителя. Это классическая проблема циклических ссылок, и в этом случае прибегают к weak ссылкам. В новых XE делфях можно написать так:
TNode = class(TInterfacedObject, INode)
private
  [weak] FParent: INode;
  FChild: array of INode;
end;
а в старых — хранят Pointer:
TNode = class(TInterfacedObject, INode)
private
  FParent: Pointer;
  FChild: array of INode;
end;
Это позволяет обойти автоинкремент счетчиков, и теперь если мы потеряем указатель на родителя — все дерево прибьется, что и требовалось получить.

У weak ссылок есть другая сторона. Если вдруг у вас уничтожился объект, а кто-то держит на него weak ссылку — вы не можете это отследить. По факту — у вас просто мусорный указатель, при обращении по которому будет ошибка. И это ужасно. Нужно лепить какую-то систему чистки этих самых ссылок.

Но есть очень элегантное решение. И вот как это работает. Мы пишем интерфейс weak ссылки и класс, реализующий его:
  IWeakRef = interface
    function IsAlive: Boolean;
    function Get: IUnknown;
  end;

  TWeakRef = class(TInterfacedObject, IWeakRef)
  private
    FOwner: Pointer;
  public
    procedure _Clean;
    function IsAlive: Boolean;
    function Get: IUnknown;
  end;

procedure TWeakRef._Clean;
begin
  FOwner := nil;
end;

function TWeakRef.Get: IUnknown;
begin
  Result := IUnknown(FOwner);
end;

function TWeakRef.IsAlive: Boolean;
begin
  Result := Assigned(FOwner);
end;
Тут обычный typecast до Pointer-а. Именно та weak ссылка, о которой я рассказывал выше. Но ключевой метод — IsAlive, который возвращает True — если объект на который ссылается weak ссылка — еще существует. Осталось только понять как красиво почистить FOwner.
Пишем интерфейс:
  IWeakly = interface
  ['{F1DFE67A-B796-4B95-ADE1-8AA030A7546D}']
    function WeakRef: IWeakRef;
  end;
который возвращает weak ссылку и пишем класс, реализующий этот интерфейс:
  TWeaklyInterfacedObject = class(TInterfacedObject, IWeakly)
  private
    FWeakRef: IWeakRef;
  public
    function WeakRef: IWeakRef;
    destructor Destroy; override;
  end;

destructor TWeaklyInterfacedObject.Destroy;
begin
  inherited;
  FWeakRef._Clean;
end;

function TWeaklyInterfacedObject.WeakRef: IWeakRef;
var obj: TWeakRef;
begin
  if FWeakRef = nil then 
  begin
    obj := TWeakRef.Create;
    obj.FOwner := Self;
    FWeakRef := obj;
  end;
  Result := FWeakRef;
end;
Мы просто добавили метод, раздающий всем одну weak ссылку. А поскольку сам объект всегда знает о своей weak ссылке — он просто чистит её в своем деструкторе. Осталось теперь только наследоваться от TWeaklyInterfacedObject вместо TInterfacedObject, и все. Никаких больше unsafe приведений типов, выстрелов в ногу, и нецензурной брани.

Трюк 2. Механизм подписчиков


Если вы еще не велосипедили систему плагинов в делфи и не использовали MVC паттернов — то вы счастливчик. В делфи все события — это просто один или два указателя на функцию(и инстанс). Поэтому если вы создали класс, сделали ему OnBlaBla свойство — то только кто-то один может узнать, что этот самый BlaBla наконец то произошел. Посему все начинают пилить свой механизм подписок, и часто тонут в отладке этих самых подписок.
События основанные на интерфейсах обычно реализуют так. Делают отдельный евент интерфейс, к примеру:
IMouseEvents = interface
  procedure OnMouseMove(...);
  procedure OnMouseDown(...);
  procedure OnMouseUp(...);
end;
и передают его, вместо классического procedure of object; например в пару Subscribe/Unsubscribe методов:
IForm = interface
  procedure SubscribeMouse(const subscriber: IMouseEvents);
  procedure UnsubscribeMouse(const subscriber: IMouseEvents);
end;
Когда код разрастается, а интерфейс IMouseEvents чуть-чуть меняется (например добавили метод) — начинает сильно напрягать рефакторинг. Например один и тот же IMouseEvents используется в IForm, IButton, IImage и прочей нечисти. Везде надо правильно поправить подписку, добавить обход по подписчикам и т.п.
Я использую следующий трюк. Пишем интерфейс:
  IPublisher = interface
  ['{CDE9EE5C-021F-4942-A92A-39FC74395B4B}']
    procedure Subscribe  (const ASubscriber: IUnknown);
    procedure Unsubscribe(const ASubscriber: IUnknown);
  end;
Класс который будет реализовывать этот интерфейс (пусть это будет TBasePublisher) умеет только добавлять и удалять из списка какие-то интерфейсы. В дальнейшем мы пишем классы, которые я называю броадкастеры. Вот у нас есть евент интерфейс:
  IGraphEvents = interface
  ['{2C7EF06A-2D63-4F25-80BC-7BA747463DB6}']
    procedure OnAddItem(const ASender: IGraphList; const AItem: TGraphItem);
    procedure OnClear(const ASender: IGraphList);
  end;
Мы наследуемся от TBasePublisher и реализуем вот такой броадкастер:
  TGraphEventsBroadcaster = class(TBasePublisher, IGraphEvents)
  private
    procedure OnAddItem(const ASender: IGraphList; const AItem: TGraphItem);
    procedure OnClear(const ASender: IGraphList);
  end;

procedure TGraphEventsBroadcaster.OnAddItem(const ASender: IGraphList; const AItem: TGraphItem);
var arr: TInterfacesArray;
    i: Integer;
    ev: IGraphEvents;
begin
  arr := GetItems;
  for i := 0 to Length(arr) - 1 do
      if Supports(arr[i], IGraphEvents, ev) then ev.OnAddItem(ASender, AItem);
end;

procedure TGraphEventsBroadcaster.OnClear(const ASender: IGraphList);
var arr: TInterfacesArray;
    i: Integer;
    ev: IGraphEvents;
begin
  arr := GetItems;
  for i := 0 to Length(arr) - 1 do
      if Supports(arr[i], IGraphEvents, ev) then ev.OnClear(ASender);
end;
то есть сам броадкастер у нас реализует евент интерфейс, и в реализации просто рассылает всем подписчикам тот же евент. Преимущество — все реализовано в одном месте, оно не скомпилируется если вы хоть немного поменяете IGraphEvents. Теперь зоопарк IForm, IButton, IImage просто создают внутри себя TGraphEventsBroadcaster и вызывают его методы, как будто у IForm всего один подписчик.

Трюк 3. Умные Weak ссылки + механизм подписчиков


Но все что я описал выше про подписчиков — плохо. Дело в том, что тут сплошь и рядом будут циклические ссылки, вы замахаетесь разбираться с порядком финализации и отписыванием. Вы добавите слабые ссылки, но погрязнете в отладке мусорных ссылок. Вот тут то и пригодятся умные слабые ссылки, описанные в самом начале. Мы просто пишем вот такой интерфейс издателя (который принимает IWeakly из начала статьи):
  IPublisher = interface
  ['{CDE9EE5C-021F-4942-A92A-39FC74395B4B}']
    procedure Subscribe  (const ASubscriber: IWeakly);
    procedure Unsubscribe(const ASubscriber: IWeakly);
  end;
Внутри себя издатель TBasePublisher хранит массив слабых ссылок TWeakRefArr = array of IWeakRef;
  TBasePublisher = class(TInterfacedObject, IPublisher)
  private
    FItems: TWeakRefArr;
  protected
    function GetItems: TWeakRefArr;
  public
    procedure Subscribe  (const ASubscriber: IWeakly);
    procedure Unsubscribe(const ASubscriber: IWeakly);
  end;
А броадкастер теперь только проверяет слабую ссылку на жизнеспособность, получает нормальную, и направляет евент в неё. Броадкастер поменялся вот так:
procedure TGraphEventsBroadcaster.OnAddItem(const ASender: IGraphList; const AItem: TGraphItem);
var arr: TWeakRefArr;
    i: Integer;
    ev: IGraphEvents;
begin
  arr := GetItems;
  for i := 0 to Length(arr) - 1 do
    if IsAlive(arr[i]) then
      if Supports(arr[i].Get, IGraphEvents, ev) then ev.OnAddItem(ASender, AItem);
end;

procedure TGraphEventsBroadcaster.OnClear(const ASender: IGraphList);
var arr: TWeakRefArr;
    i: Integer;
    ev: IGraphEvents;
begin
  arr := GetItems;
  for i := 0 to Length(arr) - 1 do
    if IsAlive(arr[i]) then
      if Supports(arr[i].Get, IGraphEvents, ev) then ev.OnClear(ASender);
end;
Теперь нас абсолютно не заботит порядок отписывания. Если мы забыли отписаться — ничего страшного. Все стало прозрачно, как в дотнете и должно было быть.

Трюк 4. Перегрузка в помощь


Последний штрих:
  TAutoPublisher = packed record
    Publisher: IPublisher;
    class operator Add(const APublisher: TAutoPublisher; const ASubscriber: IWeakly): Boolean;
    class operator Subtract(const APublisher: TAutoPublisher; const ASubscriber: IWeakly): Boolean;
  end;

class operator TAutoPublisher.Add(const APublisher: TAutoPublisher;
  const ASubscriber: IWeakly): Boolean;
begin
  APublisher.Publisher.Subscribe(ASubscriber);
  Result := True;
end;

class operator TAutoPublisher.Subtract(const APublisher: TAutoPublisher;
  const ASubscriber: IWeakly): Boolean;
begin
  APublisher.Publisher.Unsubscribe(ASubscriber);
  Result := True;
end;
Я думаю он понятен без слов. Мы просто делаем MyForm.MyEvents + MySubscriber; — мы подписались. Вычли: MyForm.MyEvents — MySubscriber; — отписались.

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

IntfEx.pas — реализация умных слабых ссылок, базового класса издателя TBasePublisher на слабых ссылках + перегрузка через структуру TAutoPublisher
Datas.pas — список нарисованных обектов + евент интерфейс при изменении этого списка
DrawForm.pas — класс реализующий форму на которой можно рисовать. Там же происходит подписка на евенты.
HiddenForm.pas — скрытая главная форма (нужна лишь для того чтобы Application крутил оконный цикл)
ну и файл проекта чуть-чуть изменен (там создаются формы на которых можно рисовать)

Идея weak ссылок была придумана Дмитрием Ильиных из Maxidix s.r.o. и доработана мной.
Александр Бусаров @MrShoor
карма
126,5
рейтинг 3,3
Пользователь
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (26)

  • +1
    В старых не хранят Pointer, а используют приведение к нему при сохранении интерфейса.
    • 0
      Можно поподробнее? Что значит «при сохранении интерфейса»?
      • 0
        Плохо выразился. В старых не надо объявлять поле типа Pointer, в старых объявляется поле интерфейсного типа, а при присвоении ему значения делается приведение: Pointer(FInterfaceField) := Pointer(InterfaceVariable);
        • 0
          Нигде не встречал такой конструкции. А зачем так делать? Чтобы получить AV при выходе поля за область видимости? Или вы предлагаете вручную чистить поля потом?
          • 0
            Так делали, чтобы получить weak-ссылку и при этом не заниматься многочисленными приведением Pointer к интерфейсу.
            Не будет никакого AV, если вы руками присвоите nil в деструкторе, тоже через приведение к Pointer.
            • 0
              Не будет AV если… Это в любом случае отчаянная попытка выстрелить себе в ногу. Через пол года откроете модуль, увидите там FInterfacedFiled: IMyInterface, и забудете вот так Pointer(FInterfaceField) := Pointer(InterfaceVariable) перезаписать. Да и на практике я подобные вещи ни разу в жизни не встречал.
              А чтобы избежать постоянного тайпкаста обычно делается следующее:
              TMyClass = class
              private
                FInterfaceField: Pointer;
                function GetInterfaceField: IMyInterface;
                procedure SetInterfaceField(const Value: IMyInterface);
                property InterfaceField: IMyInterface read GetInterfaceField write SetInterfaceField;
              end;
              
              TMyClass.GetInterfaceField: IMyInterface;
              begin
                Result := IMyInterface(FInterfaceField);
              end;
              
              TMyClass.SetInterfaceField(const Value: IMyInterface);
              begin
                FInterfaceField := Pointer(Value);
              end;
              
              И пользуются после этого свойством.
  • +1
    Скомпилировалось в Delphi 2010 — основной на работе.
    За статью спасибо, однозначно в избранное.
  • 0
    А вам точно нужны эти интерфейсы везде? Мне кажется в большинстве приведенных примеров можно было обойтись просто переменными-классами нужного уровня абстракции с ручным разрушением подчиненных объектов в деструкторах родителей. Или все это с целью не отслеживать вручную время жизни объектов? Я согласен, лень двигатель прогресса, но все же все эти наслоения выглядят иногда слишком.

    Опять же такой вопрос, вот выяснили вы что WeakRef.IsAlive = false; Что программа должна с этим делать? Создать новый объект или выкинуть исключение? Или проигнорировать? Я допускаю что где-то объективно применимо одно из этих трех действий. Но в подавляющем большинстве — это ненормальное поведение, не так ли?

    Вообще, интерфейсы в Delphi были придуманы в основном для того, чтобы можно было обеспечить единый механизм взаимодействия с объектами, не имеющими общего предка с нужным интерфейсом. Но как всегда бывает с удачными инструментами — сфера их применения стала заметно шире изначально задуманной.
    • 0
      Именно, чтобы не отслеживать.

      Мы создали в одном месте некоторый объект А, передали указатель на него какому-то другому объекту Б. Допустим так случилось что объект А умирает раньше объекта Б. В этом случае нам надо как-то почистить указатель в объекте Б. Нужно придумывать механизм чистки ссылок. Хороший пример — TComponent со своим FreeNotification. Там создаются списки, компоненты друг другу добавляются в списки, при уничтожении одного из них они друг у друга удаляются из списков. При этом надо не забыть перекрыть Notification, чтобы подчистить поля.
      Ну ужас же. Разве нет? Ужас с точки зрения производительности, и ужас с точки зрения отладки.

      А IsAlive — это тоже самое что Assigned, но только для этих weak ссылок. Программа должна вести себя ровно так же, как если бы там был просто указатель на TObject равный nil. То есть эти weak ссылки надо использовать там, где подразумевается такое состояние, а это очень много где. Это и механизм подписчиков. И когда плагины друг на друга ссылаются, или скажем контроллеры в MVC. Да те же TComponent можно было реализовать без всяких FreeNotification.
      • +1
        Не, я согласен, механизм вы придумали хороший, правильный. Тем более если его применение подтверждается практикой. Как минимум время на разработку он сэкономит и простит многие вольности в управлении памятью.

        В подписках я обычно делал примерно так: подписчик получает указатель на список в котором он подписан. В деструкторе подписчик соответственно удаляет себя из этого списка. Не панацея конечно, но в большинстве случаев хватало
      • 0
        То есть вместо заботы о подчистке создаваемых объектов получаем заботу о силе ссылки?
        • 0
          Не так. Вместо заботы о подчистке всюду ссылок на уничтожаемый объект получаем заботу о силе ссылки. Вместо того чтобы думать как нам подчистить потом эту ссылку — мы просто используем слабую ссылку и все.
          • 0
            Предлагаю обсудить эту тему с разработчиками FPC, это может стать частью базовой библиотеки.
            • 0
              Да, думаю стоит попробовать, спасибо.
  • 0
    Для простых уведомлений хорошо подходят message methods — они проще и надежнее интерфейсов.
    • 0
      Они настолько разные по возможностям, и результату — что я не берусь сравнивать их. В одних случаях message удобнее, в других лучше нотификации через подписчиков. В любом случае тема не совсем об этом.
  • 0
    Довольно опасная штука.
    Обычно объект отписывается отовсюду, а потом начинает разрушаться.
    Здесь же сначала выполняется деструктор, а по его завершении происходит отписка.

    В многопоточной среде легко словить ситуацию, когда событие приходит разрушенному объекту.
    • 0
      Не могу с вами согласиться. В многопоточном коде возможны 2 варианта:
      1. Нужно учитывать, что после Unsubscribe может прийти эвент всегда.
      2. Unsubscribe вызывает блокировку одной критической секции с кодом, который файрит эвенты. Сама по себе такая блокировка опасна, ибо получить дедлок при таком подходе элементарно.
      Я использую только 1-ый подход. В обработчиках события всегда учитываю разрушается объект нет.
      • 0
        Первый подход накладывает дополнительные ограничения на пользовательские классы, в обработчиках надо везде вставлять
        if (FIsDestroying) then exit;

        Я считаю, это просто некрасиво.

        Второй подход в том виде, как выше описан, действительно легко приводит к дедлокам, но я использую следующий трюк:
        Перед циклом, который файрит эвенты, копирую список вызываемых функций в массив, и только потом вызываю делегаты из массива. У каждого паблишера своя критическая секция.
        • 0
          Перед циклом, который файрит эвенты, копирую список вызываемых функций в массив, и только потом вызываю делегаты из массива. У каждого паблишера своя критическая секция.

          В том то и дело что именно так — не корректно. После копирования в массив критическая секция уже отпущена. Другой поток может вызвать Unsubscribe в тот момент, когда уже скопированы но еще не сфайрены. Таким образом евент прилетит в уже уничтожающийся (отписавшийся) объект, и все равно надо делать что-то в духе if (FIsDestroying) then exit; чтобы все было корректно.

          p.s. Кстати если вы обратите внимание, то TBasePublisher.GetItems как раз возвращает массив. В архиве в примере к статье есть вот такая реализация:
          function TBasePublisher.GetItems: TWeakRefArr;
          var i: Integer;
          begin
            if assigned(FCS) then
            begin
              FCS.Enter;
              try
                SetLength(Result, FCount);
                for i := 0 to FCount - 1 do
                  Result[i] := FItems[i];
              finally
                FCS.Leave;
              end;
            end
            else
              Result := FItems;
          end;
          
          Критическая секция создается если класс создается как ThreadSafe в конструкторе:
          constructor TBasePublisher.Create(const AThreadSafe: Boolean);
          begin
            if AThreadSafe then FCS := TCriticalSection.Create;
          end;
          
          Ну то есть я делаю ровно так же, как вы только что описали ;)
          • 0
            Не-не-не, я не отпускаю критическую секцию при вызове делегатов.

            Копирование нужно для такого сценария:
            В момент передачи события объекту он отписывается от этого паблишера и поскольку в Unsubscribe список защищён той же секцией, что и в броадкаст-методе, а текущий поток уже владеет секцией, отписка модицирует список и итерировать по нему дальше нельзя.

            Наверное можно придумать сценарий дедлока, но пока такая схема — у каждого паблишера своя секция и она не отпускается при вызове событий — себя неплохо показывает.
            • 0
              Не-не-не, я не отпускаю критическую секцию при вызове делегатов.
              Сценарий дедлока возникает легко. Допустим у нас есть VCL и не основной поток, который файрит нотификации. Итак он сфайрил нотификацию, и вызов попадает в обработчик:
              procedure TSubscriber.OnBlaBla(Sender: IUnknown)
              begin
                //что то делаем
                //и вызваем синхронизацию в основной поток для работы с VCL
                Synchronize(DoSomething); 
                //если кто-то в процессе работы DoSomething вызовет Subscribe/Unsubscribe,
                //или создаст ситуацию когда сфайрится евент из основного потока - это будет гарантированный дедлок
              end;
              
              Увы, я слишком много стрелял себе в ногу дедлоками и сразу вижу потенциально опасные места. Единственно безопасный вариант — это не держать критическую секцию у паблишера заблокированной, когда файрятся евенты.
              • 0
                Но здесь хоть можно подумать цикл зависимостей блокировок и доказать корректность кода, а что делать с конструкцией
                if (FIsDestroying) then exit;

                Всё, начиная от этого условия до конца деструктора (до момента, когда будет обнулён указатель в IWeakly), нужно оборачивать с критическую секцию? И получение указателя из IWeakly — тоже в этой секции? Иначе возникнет ситуация, когда нотификация начала обрабатываться, и деструктор уже прошёл этот if

                • 0
                  Из тех же соображений конструкция
                  if (weakptr.IsAlive) then ptr := weakptr.Get;

                  должна быть атомарной (в одном методе, защищённом секцией).
                  • 0
                    В многопоточном коде сначала получаем указатель, потом проверяем:
                    ptr := weakptr.Get;
                    if Assigned(ptr) then
                    
                • +1
                  Да, оборачивать критической секцией. Вместе с условием.
                  FDestroyCS.Enter;
                  try
                    if (FIsDestroying) then exit;
                    //process notification
                  finally
                    FDestroyCS.Leave;
                  end;
                  

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

                  Но в случае с таким подходом мы гарантированно избегаем дедлока, а валидный дестрой объекта обеспечивается только в контексте самого объекта. Мы точно знаем что и как нам нужно сделать (чуть ли не шаблонные действия).
                  А вот если мы захотим обезопасить себя от дедлока — это практически нереально. Нужно постоянно помнить про то что у нас паблишер залочен. Любой рефакторинг через пол года/год — и привет дедлок. Хотя я раньше тоже был сторонником такого подхода.

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