Uncategorized

Lightweight Multicast Events

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 Variant2nd Variant
00Method.CodeCount
48Method.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:

  1. (Line 9) when there are no event handlers assigned yet.
  2. (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.
  3. (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:

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 regular TList<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 or TEventHandler 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.

2 thoughts on “Lightweight Multicast Events

    1. 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.

      Like

Leave a comment