Ермаков И. Е. Связывание объектов, обработчики событий, "делегаты"

Примеры созданы в процессе обсуждения1) «проблемы отсутствия делегатов (procedure of object и т.п.)» в языке Компонентный Паскаль.

Ниже иллюстрируются несколько архитектурных подходов.

Исходный пример на Active Oberon

В Active Oberon (как и во многих других языках, например, в C#) присутствуют делегаты — процедурные переменные, способные ссылаться на методы объектов. На их основе участник форума Galkov привёл пример связывания объектов-кнопок и методов объекта-плеера. Исходный текст на Active Oberon:

TYPE
  MediaPlayer = OBJECT
    PROCEDURE Play; .... play a movie .... END Play;
    PROCEDURE Stop; .... stop movie .... END Stop;
  END MediaPlayer;

  ClickProc = PROCEDURE {DELEGATE};
  Button = OBJECT
    VAR
    onClick: ClickProc;
    caption: ARRAY 32 OF CHAR;

    PROCEDURE OnClick;
    BEGIN onClick END OnClick;

    PROCEDURE & Init(caption: ARRAY OF CHAR; onClick: ClickProc);
    BEGIN SELF.onClick := onClick; COPY(caption, SELF.caption)
    END Init;
  END Button;

PROCEDURE Init(p: MediaPlayer);
  VAR b0, b1, b2: Button;
BEGIN
(* Reboot -> call system reboot function *)
  NEW(b0, "Reboot", System.Reboot);
  (* MediaPlayer UI: bind buttons with player instance *)
  NEW(b1, "Play", p.Play);
  NEW(b2, "Stop", p.Stop);
END Init;

Объекты обратного вызова (хуки)

Достаточно распространённый прием в Компонентном Паскале. Модуль, желающий сообщать о событиях, предоставляет абстрактный тип-обработчик, другие модули его реализуют, получая объекты обратного вызова. (Такие объекты-обработчики в Блэкбоксе часто называют хуками (Hook). Примером может служить модуль Log и инсталляция в него хука модулем StdLog).

Пусть имеются два модуля - Buttons и Players. Их интерфейс (DEFINITION) приведён ниже:

DEFINITION Buttons;
 
   IMPORT Views;
 
   TYPE
      Handler = POINTER TO ABSTRACT RECORD
         (h: Handler) Do-, NEW, ABSTRACT
(* знак "-" означает экспорт процедуры только для реализации. Вызывать её может только модуль Buttons *)
      END;
 
   PROCEDURE New (IN caption: ARRAY OF CHAR; handler: Handler): Views.View;
 
END Buttons.
 
DEFINITION Players;
 
   TYPE
      Player = POINTER TO ABSTRACT RECORD
         (p: Player) Play, NEW, ABSTRACT;
         (p: Player) Stop, NEW, ABSTRACT
      END;
 
   PROCEDURE New (IN mediaFile: ARRAY OF CHAR): Player;
 
END Players.

Как обычно, реальные типы объектов не экспортируются, а порождаются фабриками New. Подразумевается, что кнопка — разновидность вьюшки (объекта изображения, Views.View).

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

MODULE MyApplication;
 
   IMPORT Views, Buttons, Players;
 
   TYPE
      PlayHandler = POINTER TO RECORD (Buttons.Handler) pl: Player END;
      StopHandler = POINTER TO RECORD (Buttons.Handler) pl: Player END;
 
   PROCEDURE (h: PlayHandler) Do;
   BEGIN
      h.pl.Play
   END Do;
 
   PROCEDURE (h: StopHandler) Do;
   BEGIN
      h.pl.Stop
   END Do;
 
   PROCEDURE OpenPlayerDesk* (IN mediaFile: ARRAY OF CHAR);
      VAR pl: Players.Player;
         ph: PlayHandler;
         sh: StopHandler;
         pb, sb: Views.View;
   BEGIN
      pl := Players.New(mediaFile);
      NEW(ph); ph.pl := pl;
      NEW(sh); sh.pl := pl;
      pb := Buttons.New("Play", ph);
      sb := Buttons.New("Stop", sh);
      (* ... Выкладываем куда-нибудь кнопки pb и sb ... *)
   END OpenPlayerDesk;
 
END MyApplication.

Можно рассмотреть преимущества и недостатки такого подхода:

  • Особенность: делегирующий слой объявляется явно и типизированно (под конкретную задачу делегирования).
  • Недостаток: значительно «многословнее», требуется объявление мелких объектных типов.
  • Недостаток: порождается много лишних динамических объектов (нагрузка на сборщик мусора). Также — косвенные вызовы, но эти накладные расходы незначительны для большинства приложений.
  • Преимущество расширяемости: слой делегирования явный и может содержать любую посредническую логику. Более того, он даже приглашает нас при развитии приложения расширяться в этом месте. А посредничество, расслоение лежит, так или иначе, в основе всех компонентных паттернов. Нужна расширяемость — закладывается косвенность.
  • Концептуально: Оберон предполагает сущности, к которыми можно обращаться, двух видов: процедура и запись, имеющая интерфейс. Статический интерфейс. И два вида ссылок — на процедуры и на записи. «Разламывание» интерфейса на отдельные операции и независимая идентификация операций не предполагается. Такое «разламывание» можно рассматривать только как оптимизацию, «сахар» или «хак» для каких-то частных случаев.

Использование процедурного обработчика с объектным параметром

Важно заметить, что прямое связывание объекта-источника события с методом другого объекта обычно невозможно — в силу того, что требуется преобразование параметров, какие-то посреднические действия и проч. Таким образом, должен присутствовать объект-адаптер, который будет это осуществлять. В предыдущем примере на КП объекты, способные выполнять роль адаптера, присутствуют сразу.

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

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

Следует вспомнить, что, по сути, метод — это процедура с первым параметром - указателем на контекст (экземпляр объекта). Именно такой стиль ООП, кстати, предполагает классический Оберон и Оберон-07, в которых нет связанных с типами процедур. При таком взгляде делегат можно представить как пару из процедурной переменной и указателя на объект, типа:: RECORD handler: PROCEDURE(obj: ANYPTR; ..другие параметры..); obj: ANYPTR) END. Объект, владеющий такой парой, может инициировать для другого объекта операцию.

Пример:

DEFINITION Buttons;
 
   TYPE
      Handler = PROCEDURE (obj: ANYPTR);
 
   PROCEDURE New (IN caption: ARRAY OF CHAR; handler: Handler; obj: ANYPTR): Views.View;
 
END Buttons.
 
DEFINITION Players;
 
   TYPE
      Player = POINTER TO ABSTRACT RECORD
         (p: Player) Play ( ..какие-то параметры.. ), NEW, ABSTRACT;
         (p: Player) Stop ( ..какие-то параметры.. ), NEW, ABSTRACT
      END;
 
   PROCEDURE New (...): Player;
 
END Players.
MODULE Desks;
 
   IMPORT Views, Buttons, Players;
 
   TYPE
      Desk = POINTER TO RECORD (Views.View)
         playButton, stopButton: Views.View;
         player: Players.Player
      END;
 
   PROCEDURE PlayHandler (obj: ANYPTR);
   BEGIN
      obj(Desk).player.Play(...)
   END PlayHandler;
 
   PROCEDURE StopHandler (obj: ANYPTR);
   BEGIN
      obj(Desk).player.Stop(...)
   END Stophandler;
 
   PROCEDURE New* (...): Views.View;
      VAR d: Desk;
   BEGIN
      NEW(d); d.player := Players.New(...);
      d.playButton := Buttons.New("Play", PlayHandler, d);
      d.stopButton := Buttons.New("Stop", StopHandler, d);
   END New;
 
END Desks.

Пример с передачей через делегат расширяемого сообщения

Вышеприведённый пример слишком «игрушечный». На самом деле, при соединении объектов нужно пересылать сигналы разных типов, заранее неизвестных.

Модифицируем пример, чтобы показать более общий подход.

Процедура-делегат должна получать свой объект, объект-источник и расширяемое VAR-сообщение (см. архитектурный приём generic message bus, например, http://forum.oberoncore.ru/viewtopic.php?f=90&t=258).

Обратим внимание, что появилась возможность сконцентрировать всю обработку событий в модуле системы-агрегата в одной процедуре, которая принимает решение на основе того, какое сообщение от какого объекта получено. Разумеется, можно было оставить две отдельных процедуры, как в предыдущем примере (поклонники оптимизации заметят, что избегаются лишние проверки внутри процедуры).

DEFINITION Buttons;
 
   TYPE
      Handler = PROCEDURE (obj, sourceObj: ANYPTR; VAR msg: ANYREC);
      ClickMsg = RECORD ... END;
 
   PROCEDURE New (IN caption: ARRAY OF CHAR; handler: Handler; obj: ANYPTR): Views.View;
 
END Buttons.
 
DEFINITION Players;
 
   TYPE
      Player = POINTER TO ABSTRACT RECORD
         (p: Player) Play ( ... ), NEW, ABSTRACT;
         (p: Player) Stop ( ... ), NEW, ABSTRACT
      END;
 
   PROCEDURE New (...): Player;
 
END Players.
MODULE Desks;
 
   IMPORT Views, Buttons, Players;
 
   TYPE
      Desk = POINTER TO RECORD (Views.View)
         playButton, stopButton: Views.View;
         player: Players.Player
      END;
 
   PROCEDURE Handler (d, sourceObj: ANYPTR; VAR msg: ANYREC);
   BEGIN
      WITH d: Desk DO
         IF msg IS Buttons.ClickMsg THEN
            IF sourceObj = d.playButton THEN
               d.player.Play(...)
            ELSIF sourceObj = d.stopButton THEN
               d.player.Stop(...)
            END
         END
      END
   END Handler;
 
   PROCEDURE New* (...): Views.View;
      VAR d: Desk;
   BEGIN
      NEW(d); d.player := Players.New(...);
      d.playButton := Buttons.New("Play", Handler, d);
      d.stopButton := Buttons.New("Stop", Handler, d);
   END New;
 
END Desks.
1) см. также http://forum.oberoncore.ru/viewtopic.php?f=86&t=3549 — прим. редактора
© 2005-2018 OberonCore и коллектив авторов.