A multicast event is an event that can have multiple listeners (or event handlers). Some languages (such as .NET languages) have built-in support for multicast events. This article shows an implementation for Delphi.

This implementation is very lightweight. In fact, it takes no more resources than a regular Delphi event if there is just a single listener (or no listeners). As a bonus – with a tiny bit of code – these multicast events are compatible with regular Delphi events as well. So you can use them in your own published components and still be able to assign events using Delphi’s object inspector.
Our version uses some Delphi wizardry to accomplish this, but you don’t need to know the details of any of this to make use of them. In that case, you only have to read the Programmer’s Guide below. But if you are interested in how these multicast events work behind the scenes, then check out the remainder of this article as well. Who knows, you may learn something new.
As always, the code that accompanies this article can be found in our JustAddCode repo on GitHub in the MulticastEvents folder.
Programmer’s Guide
To use a multicast event, all you have to do is to add it to a class and provide access to it, for example through a read-only public (not published) property:
type
TMyButton = class(TControl)
private
FClickEvent: TMulticastEvent;
public
property ClickEvent: TMulticastEvent read FClickEvent;
end;
There is no need to create or initialize the multicast event. This is done automatically.
Then multiple parties can listen for the click event like this:
type
TMyForm = class(TForm)
TestButton: TMyButton;
private
procedure HandleTestButtonClick(const ASender: TObject);
public
constructor Create(const AOwner: TComponent); override;
end;
constructor TMyForm.Create(const AOwner: TComponent);
begin
inherited;
TestButton.ClickEvent.Add(HandleTestButtonClick);
end;
This will call the HandleTestButtonClick
method whenever the button is clicked. But another party can listen for click events as well:
MyForm.TestButton.ClickEvent.Add(AnotherClickHandler);
Now, whenever the button is clicked, two event handlers will be called.
You can make the event compatible with “traditional” Delphi events by adding a regular (published) OnClick
property with a custom getter and setter:
type
TMyButton = class(TControl)
private
FClickEvent: TMulticastEvent;
function GetOnClick: TEventHandler;
procedure SetOnClick(const AValue: TEventHandler);
public
property ClickEvent: TMulticastEvent read FClickEvent;
published
property OnClick: TEventHandler read GetOnClick write SetOnClick;
end;
function TMyButton.GetOnClick: TEventHandler;
begin
// A TMulticastEvent can be implicitly assigned to a TEventHandler
Result := FClickEvent;
end;
procedure TMyButton.SetOnClick(const AValue: TEventHandler);
begin
// This will clear any existing listeners and
// add the given event handler
FClickEvent.Assign(AValue);
end;
Now you can assign the OnClick
event as you normally would (though code or the object inspector in Delphi’s IDE).
For the sender to fire the click event, it only has to call its Invoke
method:
type
TMyButton = class(TControl)
private
FClickEvent: TMulticastEvent;
protected
procedure DoClick;
public
property ClickEvent: TMulticastEvent read FClickEvent;
end;
procedure TMyButton.DoClick;
begin
FClickEvent.Invoke(Self);
end;
This will call the event handler of all added listeners, passing Self
as the ASender
parameter.
If you need to, you can also remove an existing event handler:
TestButton.ClickEvent.Remove(HandleTestButtonClick);
Additional Event Parameters
So far, we have only looked at events that have a single ASender
parameter. But what if you need to support more parameters? Our implementation adds a generic multicast event type that supports an additional parameter:
type
TEventHandler<T> = procedure(const ASender: TObject;
const AArg: T) of object;
type
TMulticastEvent<T> = record
public
procedure Add(const AHandler: TEventHandler<T>);
procedure Invoke(const ASender: TObject; const AArg: T);
end;
The additional parameter is of type T
(the generic type parameter). You can use this multicast event in the same way as before. The only difference is the additional parameter.
But what if you need more than 1 additional parameter? There are two ways to accomplish this.
The first is to follow the .NET approach. In this case, you still have only 1 additional parameter, but it is of a record type that can hold multiple values. For example:
type
TPaintEventArgs = record
Canvas: TCanvas;
Rect: TRect;
end;
type
TPaintEvent = TEventHandler<TPaintEventArgs>;
type
TMyControl = class(TControl)
private
FPaintEvent: TMulticastEvent<TPaintEventArgs>;
function GetOnPaint: TPaintEvent;
procedure SetOnPaint(const AValue: TPaintEvent);
protected
procedure DoPaint(const ACanvas: TCanvas; const ARect: TRect);
public
property PaintEvent: TMulticastEvent<TPaintEventArgs>;
published
property OnPaint: TPaintEvent read GetOnPaint write SetOnPaint;
end;
function TMyControl.GetOnPaint: TPaintEvent;
begin
Result := FPaintEvent;
end;
procedure TMyControl.SetOnPaint(const AValue: TPaintEvent);
begin
FPaintEvent.Assign(AValue);
end;
procedure TMyControl.DoPaint(const ACanvas: TCanvas;
const ARect: TRect));
var
Args: TPaintEventArgs;
begin
Args.Canvas := ACanvas;
Args.Rect := ARect;
FPaintEvent.Invoke(Self, Args);
end;
The second approach is to add more versions of TMulticastEvent
supporting more parameters. Our sample implementation does not use this approach, but it is easy to add it yourself:
type
TEventHandler<T1, T2> = procedure(const ASender: TObject;
const AArg1: T1; const AArg2: T2) of object;
type
TMulticastEvent<T1, T2> = record
public
procedure Add(const AHandler: TEventHandler<T1, T2>);
procedure Invoke(const ASender: TObject; const AArg1: T1;
const AArg2: T2);
end;
Although this can add numerous additional multicast event types, the implementation of these is trivial. Just take a look at the implementation of TMulticastEvent<T>
and you will see how simple it is to add multicast event types supporting more parameters.
Behind The Scenes
Our implementation of multicast events uses some Delphi wizardry and relative new Delphi language features to make them as lightweight as possible, while still being compatible with standard Delphi events. Although some of this wizardry can be considered hacks, they are safe to use with the current and future Delphi versions, and work on all supported platforms and CPU architectures. You can skip the remainder of this article if you are not interested in these. But if you want to learn more about custom managed records, variant records, operator overloading and some light bit manipulation, then keep reading. In addition, the source code is heavily documented and explains every step of the algorithms involved.
Nested Types and Variant Records
TMulticastEvent
is implemented as a record:
type
TEventHandler = procedure(const ASender: TObject) of object;
type
TMulticastEvent = record
private type
PEventHandler = ^TEventHandler;
private type
TData = record
case Byte of
0: (Method: TMethod);
1: (Count: NativeInt;
_List: UIntPtr);
end;
private
FData: TData;
...
public
procedure Add(const AHandler: TEventHandler);
procedure Assign(const AHandler: TEventHandler);
procedure Remove(const AHandler: TEventHandler);
procedure Invoke(const ASender: TObject);
...
end;
The record contains 2 nested types (PEventHandler
and TData
). In case you are not familiar with nested types, these are regular Delphi types, but exist only in the context of an outer (container) type. When a nested type is private (as is the case here), then it can only be used inside the outer type (TMulticastEvent
in this case). When a nested type is public, you can also use it outside of the outer type, but you have to fully qualify its name:
var SomeData: TMulticastEvent.TData;
(Although this will not compile in this case because the nested type is private). You can use nested types with records and classes, and it supports multiple levels of nesting.
The nested TData
type is a variant record (not to be confused with the Variant
data type). A variant record can contain regular fields and a variant part. The variant part looks like a case
statement, and contains one or more variants which share the same memory space. That last part is key: the variants share the same memory space. The case statement uses some ordinal type for the values in the case statement (Byte
in this example). This does not mean that the record contains a value of this type, but only that this type is used as the type of the values in the case statement.
We use a variant record to store either a single event handler, or a list of event handlers.
TData
does not contain any regular fields, but it does contain a variant part with 2 variants. The first variant is used in case there is only a single event handler (or no event handlers). It contains just one field called Method
of type TMethod
. TMethod
is declared in the System
unit and can be used to store any Delphi method (including event handlers). A method is just a pair of pointers: a pointer to the address of the code for the method (TMethod.Code
) and a pointer to the object (class instance) on which to call the method (TMethod.Data
):
type
TMethod = record
Code, Data: Pointer;
end;
The second variant is used in case there are 2 or more event handlers. It contains 2 fields. The Count
field contains the number of items in the list. It is declared as a NativeInt
so it will use 32 bits on a 32-bit CPU, and 64 bits on a 64-bit CPU. This is important, because this field must alias (that is, use the same memory space as) the TData.Method.Code
field. The _List
field contains a pointer to the list of event handlers. However, it is declared of type UIntPtr
(which has the same size as a pointer) instead of a pointer to make it a bit easier to use in calculations. This _List
field aliases the TData.Method.Data
field. (I used an underscore in the _List
name to avoid using it directly because its value must be manipulated before it can be used as a real list).
So in memory, the TData
record looks like this:
Offset (32-bit) | Offset (64-bit) | 1st Variant | 2nd Variant |
---|---|---|---|
0 | 0 | Method.Code | Count |
4 | 8 | Method.Data | _List |
This means that when you change the Method.Code
value, this will change the Count
value as well and vice versa. So the total size of TData
(and thus TMulticastEvent
) is only 8 bytes on 32-bit systems, and 16 bytes on 64-bit systems, just like a regular Delphi event.
However, we need a way to know if a multicast event contains a single event handler or a list of event handlers. We could add a Boolean
flag to TData
to indicate this, but this adds additional overhead. Instead, we take advantage of the fact that Delphi objects (or any dynamically allocated values) are always 8-byte aligned. This means that the lower 3 bits of the TMethod.Data
pointer (and thus also the _List
value) are not used and always 0. We can use one of these bits to indicate if the event has a single handler (0) or a list of handlers (1). We use the lowest bit for this purpose.
Adding an Event Handler
With the data layout in place, we can write the code to add an event handler:
procedure TMulticastEvent.Add(const AHandler: TEventHandler);
var
List: PEventHandler;
begin
if (not Assigned(AHandler)) then
Exit;
if (FData.Method.Data = nil) then
FData.Method := TMethod(AHandler)
else if (not IsList) then
begin
GetMem(List, 2 * SizeOf(TEventHandler));
List^ := TEventHandler(FData.Method);
SetList(List);
Inc(List);
List^ := AHandler;
FData.Count := 2;
end
else
begin
Assert(FData.Count > 1);
List := GetList;
ReallocMem(List, (FData.Count + 1) * SizeOf(TEventHandler));
SetList(List);
Inc(List, FData.Count);
List^ := AHandler;
Inc(FData.Count);
end;
end;
This looks a bit complicated, so lets go though it step-by-step. The code needs to handle 3 situations:
- (Line 9) when there are no event handlers assigned yet.
- (Starting at line 12) when there was a single event handler, but it needs to be turned into a list containing 2 event handlers now.
- (Starting at line 22) when there is already a list of event handlers, and we need to add one.
Situation 1: no event handlers yet
In the first situation (line 9), we can just assign the event handler to the variant FData.Method
field. Remember that a TMethod
can hold any Delphi method (including event handlers) so we can safely typecast a TEventHandler
to a TMethod
.
Otherwise, we need to check if the multicast event already contains a list. We use the IsList
helper function for this:
function TMulticastEvent.IsList: Boolean;
begin
Result := Boolean(FData._List and 1);
end;
This code checks the lowest bit of FData._List
and converts it to a Boolean
flag. As mentioned above, this bit is used to distinguish between single-handler and list-of-handlers scenarios.
Situation 2: turn single handler into a list of handlers
If IsList
returns False
, then the multicast event only contains a single event handler stored in the variant FData.Method
field. We need to turn this into a list of 2 event handlers (line 12), where the first event handler is set to the one already stored in FData.Method
(line 13), and the second event handler is set to the new one (line 17). The variant FData.Count
field is set to 2 (the number of items in the list, line 18).
The SetList
helper is used to store the list inside the multicast event:
procedure TMulticastEvent.SetList(const AList: PEventHandler);
begin
FData._List := UIntPtr(AList) or 1;
end;
The code sets the lowest bit of the variant FData._List
field to 1 to indicate that the multicast event contains a list of event handlers now.
Situation 3: add handler to existing list
If IsList
returns True
, then the multicast event already contains a list of (2 or more) event handlers and we need to add the new one (line 22). To do this, we must first get a pointer to the list using the GetList
helper function:
function TMulticastEvent.GetList: PEventHandler;
begin
Result := PEventHandler(FData._List and not 1);
end;
Because the lowest bit of FData._List
is set to 1 (to indicate that the multicast event already contains a list), we need to clear it, and then typecast the result to a PEventHandler
type.
Now we need to grow the list, by reallocating it for one additional handler (line 24). We need to call SetList
again because the memory location may have been changed by ReallocMem
.
Then we need to locate to position in the list of the new handler (line 27), assign the new handler (line 28) and increase the count (line 29).
Invoking the Event
The hardest part is done now. Most of the other code should make more sense now that the concepts have been introduced. For example, invoking the event is relatively straightforward now:
procedure TMulticastEvent.Invoke(const ASender: TObject);
begin
if (IsList) then
begin
Assert(FData.Count > 1);
var List := GetList;
for var I := 0 to FData.Count - 1 do
begin
List^(ASender);
Inc(List);
end;
end
else if (FData.Method.Data <> nil) then
TEventHandler(FData.Method)(ASender);
end;
If the multicast event contains a list of handlers, then we iterate the list and invoke each handler. The only thing that may look a bit weird is the caret symbol (^) on line 9. Because List
is of type PEventHandler
, we must dereference it with ^ operator before we can call it. Most of the time, pointer dereferencing is not required in Delphi and you can skip the ^ symbol. This is one of the exceptions where it is required though or the code will not compile.
If the multicast event does not contain a list of handlers, they we check if it contains a single handler by testing the FData.Method
field. If assigned, we typecast the method to a TEventHandler
and call it. This may also look a bit weird because of the double parenthesis, but it is perfectly fine.
I won’t go into much detail of the other code, but I will briefly touch on two other Delphi language features that are used in this implementation that you may or may not be familiar with: operator overloading and custom managed records.
Operator Overloading
Delphi records support overloaded operators. For example, you can write an operator to add two records together (such as TPoint
‘s or various vector and matrix types in my FastMath library). Or you can write an operator to check two records for equality (like the TMethod
record mentioned earlier).
TMulticastEvent
uses an implicit
operator to implicitly convert a TMulticastEvent
to a TEventHandler
. We used this feature earlier to make multicast events compatible with regular Delphi events. It looks like this:
type
TEventHandler = procedure(const ASender: TObject) of object;
type
TMulticastEvent = record
public
...
class operator Implicit(
const AEvent: TMulticastEvent): TEventHandler;
...
end;
{ TMulticastEvent }
class operator TMulticastEvent.Implicit(
const AEvent: TMulticastEvent): TEventHandler;
begin
if (AEvent.IsList) then
begin
Assert(AEvent.FData.Count > 1);
var List := AEvent.GetList;
Result := TEventHandler(List^);
end
else
Result := TEventHandler(AEvent.FData.Method);
end;
The implementation isn’t that important here, but it looks similar to the Invoke
method discussed above: if there is a list of handlers, it returns the first event handler in the list; otherwise it returns the event handler stored in the FData.Method
field.
Custom Managed Records
The final piece of magic that makes this all possible are Custom Managed Records (CMR), introduced in Delphi 10.4. I already wrote 3 posts on this since it can be a very powerful feature:
- Wrapping C(++) APIs with Custom Managed Records
- Automate Restorable Operations with Custom Managed Records
- Custom Managed Records for Smart Pointers
In short, CMRs enable you to write code that is automatically executed when a CMR is initialized or goes out of scope, or when a CMR is copied.
TMulticastEvent
is implemented as a CMR so it can automatically free the list of event handlers when the record goes out of scope. This way, users of TMulticastEvent
don’t have to do anything to manage the memory it uses. The implementation is very simple:
class operator TMulticastEvent.Finalize(var ADest: TMulticastEvent);
begin
if (ADest.IsList) then
FreeMem(ADest.GetList);
end;
This introduces an issue though: when you assign one TMulticastEvent
to another one, it will copy the list instance (that is, it makes a “shallow” copy). Then when both multicast events go out of scope, that same list will be destroyed twice, resulting in a memory error.
To handle this, you must write a CMR Assign
operator. In this operator, we could make a “deep” copy of the list, by allocating a new list and copying over all the event handlers from the source list to the destination list. However, in our case, TMulticastEvent
‘s shouldn’t be copied at all; you should only add them to your class and forget about it. So instead, we just raise an exception in the Assign
operator:
class operator TMulticastEvent.Assign(var ADest: TMulticastEvent;
const [ref] ASrc: TMulticastEvent);
begin
raise EInvalidOperation.Create('Multicast events cannot be copied');
end;
Side note: if you use code completion (Ctrl+Shift+C) in the IDE to create the implementation of this operator, then Delphi will forget to add the
[ref]
attribute, resulting in a compile error. You need to add this attribute manually. Hopefully this will be fixed in a future Delphi version.
Improvements
Even though this implementation of multicast events can be very useful, it is just a sample implementation that has room for improvements. A couple come to mind:
- Growing the list of event handlers in the
Add
method could be more memory efficient. Currently, it grows the list with a single item at a time, which can increase memory fragmentation. It could be more efficient to grow the list multiple items at a time to avoid this (like a regularTList<T>
does). However, this requires keeping track of the capacity of the list in addition to the count. In addition, it will increase overall memory usage. - Instead of using an array-like list, you could also use a linked list (singly- or doubly-linked).
- In this implementation, when you assign a regular Delphi event to a multicast event, it will clear any existing event handlers and then add the new one. You may prefer to add it to the front of the list instead.
- As mentioned earlier, you can add multiple versions to handle a different number of parameters for event handlers (
TMulticastEvent<T1, T2>
,TMulticastEvent<T1, T2, T3>
etc.). This may look like a lot of extra code, but the implementation of these would all use the “base”TMulticastEvent
record, so the additional code would be very little. - You could add support for event handlers that return a value of a certain (generic) type, or that contain
var
parameters.
As a final note, I used the term “multicast events” because this terminology may be more familiar to Delphi developers. In other languages (like .NET languages) events can be multicast by default, and you will see the term “multicast delegates”, which form the basis for these events. A delegate is similar to a function pointer (like Delphi’s
TNotifyEvent
orTEventHandler
discussed above). A multicast delegate is a built-in language feature that represents a list of function pointers.
As always, let us know in the comments if you have any questions, need clarification or know of other improvements.
Nice use of CMRs – I really like that multicast implementation.
LikeLike
Thanks, much appreciated! I really like CMRs and other (relatively) recent language changes (such as inline variables). It opens the door for new possibilities like this.
LikeLike