FinalBuilder's IDE is message driven. When you select an action in an actionlist for example, the actionlist view publishes a message to which other parts of the IDE can subscribe. The publish/subscribe mechanism used in the IDE has gone through a few revisions over the years, in the quest for the perfect publish/subscribe mechanism...
Of course when I say "perfect", what I'm really after is the architecture that is the easiest to use, and more importantly, the easiest to maintain. In earlier versions of the IDE, we used simple interfaces with an event args object that can carry various payloads :
type
IEventArgs = interface
function EventID : integer;
..
property IntfData : IInterface..
property StringData : string...
property IntData : integer..
...
end;
ISubscriber = interface
procedure OnIDEEvent(const AEventArgs : IEventArgs);
end;
This has worked well for a few revisions, but with FinalBuilder 7 we have a new IDE, the number of messages being published has doubled, and code was starting to look pretty ugly. Each subcriber's OnIDEEvent method was one long case statement... and we were always having to refer back to where the message was sent from to confirm exactly what the payload was. Not at all scalable. So I started looking for new ideas. In a c# app that a colleague here wrote, he used interfaces with generics to create a publish/subscribe mechanism :
public interface IConsumer { }
public interface IConsumerOf : IConsumer where T : IMessage
{
void Consume(T message);
}
Well, Delphi 2010 has generics support and I'm already making extensive use of them (hard to imagine now how I did without them!), so I figured I'd see if I could use the same technique in delphi :
IMessage = interface
['{BCBD228C-F184-461F-B4EE-2FC7A757C0AC}']
end;
ISubScriber = interface
['{B0D49727-272F-4D51-98B3-AA6E0708DD44}']
end;
//note the constraint so only message objects can be published
ISubScriberOf = interface(IConsumer)
//No guid for generic interfaces!
procedure Consume(const message : T);
end;
TMyMessage = class(TIntefacedObject, IMessage)
end;
TMyOtherMessage = class(TIntefacedObject, IMessage)
end;
TMySubscriber = class(TIntefacedObject,ISubScriberOf, ISubScriberOf)
protected
procedure Consume(const message : TMyMessage);overload;
procedure Consume(const message : TMyOtherMessage);overload;
....
end;
Works perfectly.... except now I have a bunch of methods named Consume... and my class needs to implement an interface per message. According to my well thumbed copy of Delphi in a Nutshell, a class can implement up to 9999 interfaces so that's not a problem, but, it's just not quite as neat as I'd hoped. It worked well in the c# app as there were only a few message types. Most of my subscribers are handling 20+ messages, navigating 20+ Consume methods isn't all that maintainable.
Back to looking at the mother of all case statements in my existing architecture, it reminded me of a WndProc method and that got me thinking. How do message handlers work in the VCL? You know, this sort of thing :
procedure WMActivate(var Message: TWMActivate); message WM_ACTIVATE;
I had always just assumed it was compiler magic... (back to Delphi in a Nutshell again), well they are dynamic methods, which are invoked via TObject.Dispatch. This method takes a message record, and based on the message id, finds the matching method in the object's dynamic method table (which is compiler generated), if not found then it looks up the class heirachy and then eventually calls DefaultHandler if no match was found. Delphi's windows controls use this mechansim to dispatch windows messages, but there's really nothing windows specific about it and it looks like it could be used for any messages. The key is that the messages must be records because TObject.Dispatch treats them as such, and it looks at the first 4 bytes (DWoRD) as the message id. If you look in Messages.pas you will see that most messages are typically 16 bytes long, and they are packed records. In my initial tests everything worked fine just keeping the first 4 bytes for the message id, however in my IDE strange things happened (random av's). It turns out the messages really do need to be at least 16 bytes. So my message types look like this :
type
TMyMessage = packed record
MsgID : Cardinal;
Unused : array[1..12] of Byte;
MyPayload : whatever;
.....
constructor Create(APayload : whatever);
end;
Note that I use a constructor on the record. Constructors on records are really just psuedo constructors.. but they serve a purpose here.. this is where I make sure the message gets assigned the correct message id (from a constant). Without the constructor we would have to assign it to the message before it is sent.. that opens the possibility of used the wrong id by mistake, which could cause random access violations :
constructor TMyMessage.Create(APayload : whatever);
begin
MsgID := IDE_MYMESSAGE; //constant
MyPayLoad := APayload;
end;
One caveat with constructors on records is that they must have at least one parameter, so if your message has no payload then just use a dummy parameter. Our Subscriber interface now looks like this :
type
ISubscriber = interface
procedure Dispatch(var Message);
end;
The Dispatch message on the interface is declared the same as TObject.Dispatch, and TObject.Dispatch is our actual implementation on our subscriber class so we don't need to do much other than declare that it implements the interface, and then provide the message handlers :
TMySubscriber = class(TInterfacedObject,ISubscriber,...)
protected
procedure DoMyMessage(var Message : TMyMessage);message IDE_MYMESSAGE;
....
end;
Our publisher interface is quite simple too :
IPublisher = inteface
procedure SendMessage(var Message);
procedure Subscribe(const subscriber : ISubscriber; const filter : integer = 0);
procedure UnSubscribe(const subscriber : ISubscriber);
end;
The SendMessage method takes an untype var parameter (just like Dispatch), so any message type can be passed to it. The Subscribe method has an extra parameter, filter, which allows you to specify which messages a subscriber is interested in. If it's not specified then all messages will be passed to the subscribers Dispatch method. The filter isn't strictly neccessary, but it does provide an opportunity for a small optimisation if the subscriber really only handles a few messages.
So is this the "perfect" publish/subscribe mechanism for delphi? Probably not. It's kinda neat how it's using something that's been there in TObject probably back as far as Delphi 1 (I will have to dig out my D1 source disk and have a look!). We spent a day replacing our old mechanism with this one throughout the entire FB7 IDE, and it's performing flawlessly. I've read that dynamic methods are a bit slower than non dynamic methods.. but to be honest we haven't noticed any change in performance.. what we have noticed is how much easier our code is to read and maintain.
A sample D2010 app with full source is available here.