Sunday, October 28, 2012

Automagically Creating Object Fields with RTTI

While working on an internal project I came into a situation where a user (that is, a fellow programmer) would have to create a hierarchy of classes. As it turned out, this hierarchy would contain almost no implementation, just the class declarations, with one exceptions – every class would still have to be responsible for creating its children objects. Then I had a thought. Maybe I could use attributes and RTTI to do that in one central place instead of in every object.

The Problem

I want to implement following classes …

type
  TObjectB = class
    FData1: integer;
    FData2: string;
    FData3: boolean;
  end; 

  TObjectA = class
  strict private
    FObjectB: TObjectB;
  public
    constructor Create;
    destructor Destroy; override;
  end;

{ TObjectA }

constructor TObjectA.Create;
begin
  inherited Create;
  FObjectB := TObjectB.Create;
end;

destructor TObjectA.Destroy;
begin
  FreeAndNil(FObjectB);
  inherited;
end;

… but I’m too lazy to write constructor and destructor. What can I do?

The Result

Given the proper infrastructure, the code above can be rewritten as follows.

type
  TObjectB = class
    FData1: integer;
    FData2: string;
    FData3: boolean;
  end;

  TObjectA = class(TGpManaged)
  strict private
    [GpManaged]
    FObjectB: TObjectB;
  end;

All the implementation is hidden in the TGpManaged class, which is described below. It is implemented in the GpAutoCreate unit, which is a part of my open-sourced GpDelphiUnits package, together with the test program TestGpAutoCreate.

The Solution

The TGpManaged class implements only a constructor and a destructor. The constructor will automatically create fields in the derived class and destructor will automatically destroy them.

type
  TGpManaged = class
  public
    constructor Create;
    destructor  Destroy; override;
  end;

As it is not always a good idea to automatically create/destroy everything, the fields that are to be managed in this way must be marked with a [GpManaged] attribute which is implemented in the same unit.

type
  GpManagedAttribute = class(TCustomAttribute)
  public type
    TConstructorType = (ctNoParam, ctParBoolean);
   strict private
    FBoolParam      : boolean;
    FConstructorType: TConstructorType;
  public
    class function  IsManaged(const obj: TRttiNamedObject): boolean; static;
    class function GetAttr(const obj: TRttiNamedObject;
      var ma: GpManagedAttribute): boolean; static;
    constructor Create; overload;
    constructor Create(boolParam: boolean); overload;
    property BoolParam: boolean read FBoolParam;
    property ConstructorType: TConstructorType read FConstructorType;
  end;

The attribute can be specified in two ways – either as [GpManaged] (which will call the parameterless Create constructor to create the attribute object) or as a [GpManaged(false)] or [GpManaged(true)] which will call the Create with a boolean parameter. A field marked with the former version will be created by a call to the parameterless constructor and a field marked with the latter version will be created by a call to a constructor accepting one boolean parameter. Support for constructors with different parameter lists can be added at will.

The boolean-parameter-constructor version was added specifically for the TObjectList creation. Calling TObjectList.Create will create an object list owning its items, which is OK in most cases. If you, however, want the object list not to own its items, you have to create it as follows.

  [GpManaged(false)]
FList2: TObjectList;

For further details on the GpManagedAttribute implementation, see the source code.

Creating Fields

Fields marked with any version of the [GpManaged] attribute are created in the TGpManaged.Create constructor.

The code first accesses the enhanced RTTI context and finds the information for the object that is being constructed (ctx.GetType(Self.ClassType)). Next it iterates over all fields defined in this object.

For each field it verifies if the feld was marked with the [GpManaged] attribute. If not, next field is tested.

Otherwise, the code loops over all methods called Create. (I intentionally left out the support for constructors not named Create.) For each method the code checks whether the constructor has appropriate number and type of parameters.

In both cases, the code calls ctor.Invoke(f.FieldType.AsInstance.MetaclassType) to invoke the constructor. Appropriate parameters are passed in the second parameter. The result is stored into the field by calling the f.SetValue and whole procedure is repeated for the next field.

constructor TGpManaged.Create;
var
  ctor  : TRttiMethod;
  ctx   : TRttiContext;
  f     : TRttiField;
  ma    : GpManagedAttribute;
  params: TArray<TRttiParameter>;
  t     : TRttiType;
begin
  ctx := TRttiContext.Create;
  t := ctx.GetType(Self.ClassType);
  for f in t.GetFields do begin
    if not GpManagedAttribute.GetAttr(f, ma) then
      continue; //for f
    for ctor in f.FieldType.GetMethods('Create') do begin
      if ctor.IsConstructor then begin
        params := ctor.GetParameters;
        if (ma.ConstructorType = GpManagedAttribute.TConstructorType.ctNoParam) and
           (Length(params) = 0) then
        begin
          f.SetValue(Self, ctor.Invoke(f.FieldType.AsInstance.MetaclassType, []));
          break; //for ctor
        end
        else 
        if (ma.ConstructorType = 
             GpManagedAttribute.TConstructorType.ctParBoolean) and
           (Length(params) = 1) and
           (params[0].ParamType.TypeKind = tkEnumeration) and
           SameText(params[0].paramtype.name, 'Boolean') then
        begin
          f.SetValue(Self, 
            ctor.Invoke(f.FieldType.AsInstance.MetaclassType, [ma.BoolParam]));
          break; //for ctor
        end;
      end;
    end; //for ctor
  end; //for f
end;

Destroying Fields

Fields are destroyed in the similar manner except that the Destroy destructor is called instead of constructor. The code is much simpler because it doesn’t have to check which destructor to call.

destructor TGpManaged.Destroy;
var
  ctx : TRttiContext;
  dtor: TRttiMethod;
  f   : TRttiField;
  t   : TRttiType;
begin
  ctx := TRttiContext.Create;
  t := ctx.GetType(Self.ClassType);
  for f in t.GetFields do begin
    if not GpManagedAttribute.IsManaged(f) then
      continue; //for f
    for dtor in f.FieldType.GetMethods('Destroy') do begin
      if dtor.IsDestructor then begin
        dtor.Invoke(f.GetValue(Self), []);
        f.SetValue(Self, nil);
        break; //for dtor
      end;
    end; //for dtor
  end; //for f
end;

Potential Problems

You should always keep in mind that this approach is much slower than the “manual” way. I didn’t make any tests but I wouldn’t be surprised if the automatic way is 100 times slower. It is, however, fast enough to manage objects that are created/destroyed only occasionally.

The other problem is that you have to change the parent of your classes to TGpManaged. If this is not an option and you still want to use the “automagic” lifecycle management, you will have to copy my code into your base classes.

Update 2012-10-29

As suggested by [Stefan Glienke] in comments I have refactored field creation/destruction code into class procedures CreateManagedChildren and DestroyManagedChildren which you can call from your base classes to achieve the same functionality.

8 comments:

  1. In order to use any class, you may be able to hook the constructor and destructor (if both are virtual), following the interceptor pattern.

    Such a nice feature should be made available at RTL level, IMHO.
    With a common/standard way of implementing dependency injection, for the same price.

    But I'm not sure that Embarcadero is going into this direction, sadly.

    ReplyDelete
  2. Really cool. However I would extract the autocreate logic into a separate (static) class and only call it from your TGpManaged.Create/Destroy. That way everyone can easily use it even not inheriting from TGpManaged.

    Also a small adjustment. Checking for the boolean Parameter can be made easier by just checking for the typeinfo: params[0].ParamType.Handle = TypeInfo(Boolean)

    ReplyDelete
    Replies
    1. Stefan, thanks on both counts! Extracting the autocreate logic is a great idea and I totally forgot about that old Typinfo boolean trick.

      Delete
    2. Updated code is in the SVN. Thanks for your suggestions!

      Delete
  3. seems lacking owner/child constructor automation like NewObject := TNewObject.Create(const OwnerObject);

    ReplyDelete
  4. That's pretty cool. I like it when developers implement higher levels of thinking.

    ReplyDelete