Multidifusión en Delphi
Estoy programando un "monitor", que captura y muestra información sobre unos aparatos industriales. Puede parecer divertido, pero en realidad es todo lo contrario. La base de esta aplicación, y de otra que terminé hace unos meses es muy simple: cada cierto tiempo, preguntas por el estado de una estructura de datos gestionada por una DLL, que a su vez utiliza el puerto serie para mantener actualizada esa estructura.
Lo más significativo de esta arquitectura es que el programa es quien lleva la iniciativa. Al menos en este sistema, no hay interrupciones de hardware que te avisen de situaciones interesantes (como "la caldera está a punto de reventar", o "el operador debe estar haciendo méritos para un transplante de pulmón, porque ya se ha metido media cajetilla de cigarros entre pecho y espalda"). Nada de eso, señor: cada cierto tiempo, pregunte usted por la presión en la junta X199 y por el nivel ambiental de CO2. Y actúe de acuerdo a su conciencia...
MULTIDIFUSION
Además, ni siquiera es necesario alcanzar una precisión mediana para el intervalo entre mediciones. ¿Solución? Un cutre temporizador, de esos que depositan un mensaje de baja prioridad en la cola de mensajes de la aplicación. Un triste y viejo TTimer, de los de toda la vida. Toda la aplicación gira alrededor de este componente.
El problema es que, a pesar de lo sencillo que es este planteamiento, la lógica a ejecutar en cada disparo del temporizador puede complicarse poco a poco. En la primera aplicación el cliente fue añadiendo requisitos (lo extraño hubiera sido lo contrario). Ya que controlamos tal, ¿por qué no controlamos también tal y más cuál? El problema es que la respuesta al evento del temporizador la daba un objeto de determinada clase. Cuando creció la complejidad de los requisitos, la complejidad de la clase se disparó.
Gracias al cielo, en aquella época ya estaba leyendo frenéticamente libros sobre C#. En este lenguaje, los eventos están basados en una técnica llamada multicasting, o multidifusión: a cada evento se le pueden enganchar varios manejadores a la vez. En Delphi eso no es posible, al menos directamente. Un evento en Delphi es un puntero de 8 bytes, que apunta a un objeto, y a un método específico de ese objeto. En los lenguajes de la plataforma .NET, un delegado, que es el tipo base que se utiliza para implementar eventos, es una clase con 12 bytes de información útil: nuevamente, tenemos un puntero al objeto y un puntero al método... más un puntero al siguiente delegado de la cadena. Un evento, por lo tanto, puede apuntar a la cabecera de una lista simplemente enlazada. Y cada nodo de la lista apunta a un método manejador.
IMPLEMENTACION EN DELPHI
Delphi no permite ese modelo de forma directa, pero podemos crear una clase que, por una parte, sustituya al temporizador, y "dispare" un evento cada cierto período. Podemos entonces mantener una lista de objetos interesados en los avisos de nuestro sucedáneo del temporizador.
Para ser más exacto, lo que contendrá nuestro temporizador es una lista de punteros a interfaces. Nuestra primera definición será la del tipo IDataConsumer, un tipo de interfaz. Los objetos interesados en recibir avisos del temporizador deberán implementar esta interfaz:
type
IDataConsumer = interface
['{350517BC-D698-4B00-BDE3-78C95518F2BB}']
procedure OnData;
end;
Por cierto, el identificador único encerrado entre corchetes es un identificador arbitrario, que ha sido generado con la ayuda del editor de Delphi, pulsando Ctrl+Shift+G. En este ejemplo en particular, además, no hace falta. Pero lo he dejado, porque es difícil predecir si vas a necesitar un GUID para una interfaz cuando empiezas a diseñar.
La clase que implementa el temporizador es la siguiente:
type
TMetronome = class
private
FInterval: Value;
procedure SetInterval(Value: Integer);
protected
FDataConsumers: TInterfaceList;
procedure InternalTimerProc(var M: TMessage);
public
constructor Create(AInterval: Integer);
destructor Destroy; override;
procedure AddDataConsumer(DataConsumer: IDataConsumer);
procedure RemoveDataConsumer(DataConsumer: IDataConsumer);
property Interval: Integer read FInterval write SetInterval;
end;
El "metrónomo" contiene internamente una lista de interfaces, perteneciente a la clase TInterfaceList. El usuario de esta clase no puede tocar directamente la instancia de TInterfaceList, sino que debe utilizar los métodos AddDataConsumer y RemoveDataConsumer para añadir y quitar manejadores de eventos. La lista de interfaces se crea, como puede imaginar, en el constructor de la clase, y se destruye en el destructor:
constructor TMetronome.Create;
begin
FDataConsumers := TInterfaceList.Create;
FDataConsumers.Capacity := 8;
FInterval := DEF_INTERVAL;
FWHandle := Classes.AllocateHWnd(InternalTimerProc);
if Windows.SetTimer(FWHandle, 1, FInterval, nil) = 0 then
raise EOutOfResources.Create(SNoTimers);
end;
destructor TMetronome.Destroy;
begin
Windows.KillTimer(FWHandle, 1);
Classes.DeallocateHWnd(FWHandle);
FreeAndNil(FDataConsumers);
inherited Destroy;
end;
Observe también los detalles de la implementación del temporizador. Vamos a crear un temporizador que, cada vez que se dispare, enviará un mensaje WM_TIMER a una ventana. ¿Qué ventana usaremos como receptor del mensaje? Para no tener que depender de una ventana "visual", el objeto crea una ventana interna, con la ayuda del procedimiento AllocateHWnd, definido dentro de la unidad Classes. Este procedimiento, además de crear un objeto de ventana de Windows, asigna la dirección que pasamos como parámetro como "procedimiento de ventana" del objeto creado. En otras palabras, cada vez que se dispare el timer, se ejecutará el método InternalTimerProc... que, curiosamente, es un método de la propia TMetronome. Esta es su implementación:
procedure TMetronome.InternalTimerProc(var M: TMessage);
var
OldState: Integer;
I: Integer;
begin
if M.Msg <> WM_TIMER then
M.Result := DefWindowProc(FWHandle, M.Msg, M.wParam, M.lParam)
else if FStarted then
try
if Assigned(FDataConsumers) then
for I := 0 to FDataConsumers.Count - 1 do
IDataConsumer(FDataConsumers[I]).OnData();
except
Application.HandleException(Self);
end;
end;
Cuando este método se ejecuta, hay que distinguir en primer lugar si el mensaje que se ha recibido proviene del temporizador. Aunque el objeto o ventana que controla no es visible, de todas maneras Windows le enviará algún que otro mensaje en algún momento. Una vez que detectamos el mensaje del temporizador, recorremos la lista de interfaces, y vamos llamando el método OnData de cada uno de los objetos. Observe que capturamos las posibles excepciones que se produzcan, para no detener el proceso. Reconozco que aquí podíamos haber introducido algunas mejoras.
El resto de los métodos de la clase son triviales. Este es, por ejemplo, el método que permite modificar el intervalo del temporizador. Simplemente, destruye el temporizador existente y crea uno nuevo:
procedure TMetronome.SetInterval(Value: Integer);
const
DEF_INTERVAL = 1000;
begin
if Value <> FInterval then
begin
Windows.KillTimer(FWHandle, 1);
FInterval := Value;
if Windows.SetTimer(FWHandle, 1, FInterval, nil) = 0 then
raise EOutOfResources.Create(SNoTimers);
end;
end;
Y estos son los métodos que añaden y eliminan objetos interesados en los disparos del temporizador:
procedure TMetronome.AddDataConsumer(DataConsumer: IDataConsumer);
begin
if Assigned(DataConsumer) then
begin
FDataConsumers.Add(DataConsumer);
DataConsumer.OnData;
end;
end;
procedure TMetronome.RemoveDataConsumer(DataConsumer: IDataConsumer);
begin
repeat
until FDataConsumers.Remove(DataConsumer) = -1;
end;
Observe que RemoveDataConsumer toma precauciones ante la posibilidad de que hayamos registrado un "consumidor" más de una vez, y que AddDataConsumer dispara el receptor del evento nada más añadirlo. En mi aplicación, ese era el comportamiento adecuado. Puede que en una clase más general, esto se deba decidir mediante una propiedad de tipo lógico.
Por último, podríamos discutir si la implementación del temporizador ha sido o no la acertada. El hecho es que los temporizadores de Windows pueden funcionar depositando un evento en la cola de mensajes, como en nuestro caso, o llamando directamente a una función de respuesta. Elegí la cola de mensajes porque pensé que facilitaría la sincronización con los restantes mensajes y tareas visuales de la aplicación. Pero puede usted experimentar con la alternativa, si le interesan estas cosas.
|