Universal storage for Delphi procedural types
Delphi's procedural types fall into three categories: standalone procedures and functions, methods, and anonymous methods. The variables that can store those types are all different, both in their memory size and their nature.
Further differences between various procedural types come from the number of parameters they have and their types, as well as whether they return some result—whether they are functions. Those differences have an impact on how they are invoked, but not on their storage—the variables and their memory layout.
Two procedural types will be compatible with each other, if they are of the same category, have the same calling convention, have the same number of parameters (including the return value or lack of it). and those parameters and return value are of the same types and declared in the same order. The declared parameter names can be different, and don't affect this compatibility.
Declaring any of those procedural types has some common parts: a type identifier;
the reserved word procedure
or function
, which defines whether there will be
returned value; a list of parameters and their types; and an optional calling
convention.
A method type's declaration will have an of object
after the parameter list, while
an anonymous method declaration will start with reference to
. If neither of those is
specified, then the declared type will be a standalone procedure or function type.
Standalone procedures and functions
Standalone procedures and functions are the simplest procedural type. Their backing storage is just a pointer to the code that will be executed when invoked:
type
TProc = procedure;
var
Proc: TProc;
In essence, declaring a Proc
variable is just a strongly typed equivalent of
writing:
var
Proc: Pointer;
Methods
In Delphi, methods are procedures and functions that belong to a class, and as such, are tightly coupled to the object instance. In other words, you cannot invoke a method without knowing the object instance it belongs to.
That means that a method variable needs to store an additional data pointer, besides the pointer to the code which will run when method is invoked:
type
TProcMth = procedure of object;
var
ProcMth: TProcMth;
If we look behind the above ProcMth
variable declaration, we will se that it is
the equivalent of declaring:
var
ProcMth: record
Code, Data: Pointer;
end;
Delphi already has a built-in record type with the same layout, TMethod
, so we
can just as easily write:
var
ProcMth: TMethod;
Besides methods that operate on object instances, classes also allow declaring class methods, which operate on a class instance instead of an object instance; and static class methods, which are basically standalone procedures and functions, but organized within a class.
Anonymous methods
Anonymous methods are a different beast. They are backed by a hidden, compiler-generated,
reference-counted class, where captured variables will be hoisted and
stored as fields, and the actual code will be converted into an Invoke
method within
that class. So a reference to any instance of an anonymous method is basically an
interface reference to an object instance of that hidden class:
type
TProcRef = reference to procedure;
var
ProcRef: TProcRef;
And the above declaration can be represented with this:
type
IProcRef = interface
procedure Invoke;
end;
var
ProcRef: IProcRef;
As we can see, there is quite a difference in the way references to different kinds of procedural types are stored.
Most of the time, when we are dealing with procedural type variables, we will have a very specific procedural type in mind, and we will have no problems declaring a variable or field of that particular type to store them.
However, there are times when you will want to allow usage of any kind of procedural type in some code, as long as it has matching parameters. For instance, you may want to store some factory function, but you don't really care whether it is a standalone function, or an anonymous or object method.
Let's illustrate this with the following code:
type
TNxFunction<R> = function: R;
TNxFunctionMth<R> = function: R of object;
TNxFunctionRef<R> = reference to function: R;
INxInstanceFactory<T> = interface
function NewInst: T;
end;
TNxInstanceFactory<T> = class(TInterfacedObject, INxInstanceFactory<T>)
protected
fFunc: TNxFunction<T>;
fFuncMth: TNxFunctionMth<T>;
fFuncRef: TNxFunctionRef<T>;
public
constructor Create(const aValue: TNxFunction<T>); overload;
constructor Create(const aValue: TNxFunctionMth<T>); overload;
constructor Create(const aValue: TNxFunctionRef<T>); overload;
function NewInst: T;
end;
constructor TNxInstanceFactory<T>.Create(const aValue: TNxFunction<T>);
begin
fFunc := aValue;
end;
constructor TNxInstanceFactory<T>.Create(const aValue: TNxFunctionMth<T>);
begin
fFuncMth := aValue;
end;
constructor TNxInstanceFactory<T>.Create(const aValue: TNxFunctionRef<T>);
begin
fFuncRef := aValue;
end;
function TNxInstanceFactory<T>.NewInst: T;
begin
if Assigned(fFunc) then
Result := fFunc
else
if Assigned(fFuncMth) then
Result := fFuncMth
else
if Assigned(fFuncRef) then
Result := fFuncRef
else
Result := Default(T);
end;
Now, storing three separate variables is less than ideal, and so is the code
within the NewInst
factory function.
Now, if you just look at that piece of code, it is also not that horrible. However, if you would want to apply some similar logic to a more complex class, where you would want to store multiple factories, event handlers, or similar, it becomes clear that this kind of code is not something you would want to maintain in the long run.
Absolutely the simplest solution to the above problem, code-wise, is that you use an anonymous method for storing all of them. Standalone procedures and methods can be assigned to anonymous method: The Delphi compiler will create an anonymous method wrapper around those, and store a reference to the anonymous method.
In other words, if you have a variable of type TNxFunctionRef<R>
, you can
simply assign a value of the TNxFunction<R>
or TNxFunctionMth<R>
types to such
a variable.
So you could easily simplify the above code to the following:
TNxInstanceFactory<T> = class(TInterfacedObject, INxInstanceFactory<T>)
protected
fFuncRef: TNxFunctionRef<T>;
public
constructor Create(const aValue: TNxFunctionRef<T>);
function NewInst: T;
end;
constructor TNxInstanceFactory<T>.Create(const aValue: TNxFunctionRef<T>);
begin
fFuncRef := aValue;
end;
function TNxInstanceFactory<T>.NewInst: T;
begin
if Assigned(fFuncRef) then
Result := fFuncRef
else
Result := Default(T);
end;
That looks much better. However, there is one downside of wrapping plain procedures and methods within anonymous method: it is not the fastest, nor the most memory-efficient code one can write.
At this point, you may realize that you don't even need that whole wrapper thingy. The anonymous method reference itself is the factory. And once you eliminate the wrapper, you have eliminated the whole added overhead of wrapping other kinds of procedural types with anonymous methods.
But there is another aspect of wrapping any procedural type with an anonymous method: once you do that, you can no longer get the original content back. Yes, you can invoke the code, but if you need to pass it as parameter or assign it to some other variable which is not of an anonymous method type, then you will not be able to do that. Also, if you need to maintain some list of methods where you will need to be able to register/unregister particular ones, compare them, or similar, using an anonymous method as universal storage for all other procedural types is not an option anymore.
We have already determined that the original factory code is a bit verbose, and now we will look into whether and how it can be made more efficient. Also, we would want to make some kind of universal storage container, which will allow us to reuse that code more easily for different purposes. Again, if the actual factory functionality like the one above is all you need, then merely using an anonymous method as a factory will suffice.
What we need to store is two pointers for a method, a single pointer for a
standalone procedure or function, and an interface reference—anonymous method.
The first two we can easily squeeze together within a TMethod
record. We can
store a standalone procedure pointer into the Code
part, and use the Data
part to determine whether we are dealing with a standalone procedure or a
method. If Data
is nil
, then we have standalone procedure. If not, we have a
method.
Storage-wise, an interface reference is also a single pointer, but it also involves
triggering the reference counting mechanism. Still, we might be able to shoehorn it into
TMethod
, too. If we store it under the Data
part of TMethod
, leaving the Code
part with a nil
value, we will be able to distinguish between all three kinds
of procedural types. The only thing left is to manually trigger reference counting
when we store the reference, and when it goes out of scope.
Since one of the reasons we are writing a universal container in the first place is to gain some performance by avoiding extra allocations for the anonymous method wrapper object and its reference counting triggers, we want to base our universal container on records.
The problem with records is that they are not automatically initialized. Well, that part we can easily deal with. It is just like dealing with any other uninitialized variable.
However, if we want to squeeze an anonymous method reference into the
TMethod.Data
part, then we need to deal not only with initialization, but also
finalization. And the only advantage of having such a record wrapper comparing
to an object based one, would be avoiding additional heap allocation, but that
is a good reason enough to use records.
Here is our record-based container:
type
TNxAnyFunction<R> = record
private
Mth: TMethod;
public
constructor Create(aValue: TNxFunction<R>); overload;
constructor Create(aValue: TNxFunctionMth<R>); overload;
constructor Create(aValue: TNxFunctionRef<R>); overload;
procedure Free;
function Invoke: R;
end;
constructor TNxAnyFunction<R>.Create(aValue: TNxFunction<R>);
begin
Mth.Code := @aValue;
Mth.Data := nil;
end;
constructor TNxAnyFunction<R>.Create(aValue: TNxFunctionMth<R>);
begin
Mth := TMethod(aValue);
end;
constructor TNxAnyFunction<R>.Create(aValue: TNxFunctionRef<R>);
begin
Mth.Code := nil;
Mth.Data := Pointer((@aValue)^);
IInterface(Mth.Data)._AddRef;
end;
procedure TNxAnyFunction<R>.Free;
begin
if (Mth.Code = nil) and (Mth.Data <> nil) then
IInterface(Mth.Data)._Release;
end;
function TNxAnyFunction<R>.Invoke: R;
begin
if (Mth.Code <> nil) and (Mth.Data <> nil) then
Result := TNxFunctionMth<R>(Mth)()
else
if (Mth.Code <> nil) then
Result := TNxFunction<R>(Mth.Code)()
else
if (Mth.Data <> nil) then
Result := TNxFunctionRef<R>(Mth.Data)()
else
Result := Default(R);
end;
We can easily extend the above code with other functionality, like adding comparison
operators, Assigned
and Clear
methods, as well as getter functions which
will be able to return the original procedural type if we have a need for it. But,
the above is the most basic code we need to write for such a container.
And we can use it as follows (storing a method-based function is omitted for brevity):
function NewInt: Integer;
begin
Result := 5;
end;
var
Any: TNxAnyFunction<Integer>;
begin
Any := TNxAnyFunction<Integer>.Create(NewInt);
try
Writeln(Any.Invoke);
finally
Any.Free;
end;
Any := TNxAnyFunction<Integer>.Create(function: Integer
begin
Result := 2;
end);
try
Writeln(Any.Invoke);
finally
Any.Free;
end;
end;
Now, in the above code we are unnecessarily calling Free
in the first example
which stores a standalone function. However, the whole point of having a universal
container is to use it when we don't know what will actually be stored at
compile time. If we know for certain that we will use a standalone function, then
we wouldn't have any need for a universal container in the first place.
In newer Delphi versions, we can solve the initialization/finalization problem by using custom managed records. They do add some overhead compared to regular records, but they will also make our code simpler and safer, as compiler will take care of the necessary initialization and cleanup.
Applying custom managed records to the previous container code, we would get the following code:
type
TNxAnyFunction<R> = record
private
Mth: TMethod;
public
class operator Initialize(out Dest: TNxAnyFunction<R>);
class operator Finalize(var Dest: TNxAnyFunction<R>);
class operator Assign(var Dest: TNxAnyFunction<R>; const [ref] Src: TNxAnyFunction<R>);
constructor Create(aValue: TNxFunction<R>); overload;
constructor Create(aValue: TNxFunctionMth<R>); overload;
constructor Create(aValue: TNxFunctionRef<R>); overload;
function Invoke: R;
end;
class operator TNxAnyFunction<R>.Initialize(out Dest: TNxAnyFunction<R>);
begin
Dest.Mth.Code := nil;
Dest.Mth.Data := nil;
end;
class operator TNxAnyFunction<R>.Finalize(var Dest: TNxAnyFunction<R>);
begin
if (Dest.Mth.Code = nil) and (Dest.Mth.Data <> nil) then
IInterface(Dest.Mth.Data)._Release;
end;
class operator TNxAnyFunction<R>.Assign(var Dest: TNxAnyFunction<R>; const [ref] Src: TNxAnyFunction<R>);
begin
Dest.Mth := Src.Mth;
if (Dest.Mth.Code = nil) and (Dest.Mth.Data <> nil) then
IInterface(Dest.Mth.Data)._AddRef;
end;
...
Code from the Free
method has been moved to the Finalize
operator, and we have
an additional Initialize
operator, which makes sure we are starting with properly
initialized values, so that our finalization code would not run on random values
in situations where we merely declared a variable, but haven't filled it with
any actual value.
The Assign
operator makes sure to properly the maintain reference count of the
stored anonymous method. The regular record variant could also have an Assign
function, which could be used to copy content of one record to another,
maintaining the proper reference count. However, it is not very likely
that you will need such functionality anyway, so I omitted it from there.
It is included with the custom managed record variant, because with such a
record, the compiler could insert code behind your back which can easily mess up
the reference counting if there is no proper Assign
operator written.
If you are using older Delphi versions, or you don't want to use custom managed
records for some reason, and you want to avoid sprinkling your code
with try...finally
handlers, there is another variant you can use.
Instead of squeezing the anonymous method within the TMethod
record, you can declare
an anonymous method reference as an additional field in the container:
type
TNxAnyFunction<R> = record
private
Mth: TMethod;
Ref: TNxFunctionRef<R>;
public
...
end;
This way, the compiler will automatically initialize and handle cleanup of the
Ref
part of the record and such record does not need Free
method.
Comments
Post a Comment