Ермаков И. Е. Связывание объектов, обработчики событий, "делегаты"
Примеры созданы в процессе обсуждения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.