Thursday, September 04, 2008

Smart pointers in Delphi

Strongly-typed smart pointers are now possible in Delphi, leveraging the work on generics. Here's a simple smart pointer type:

  TSmartPointer<T: class> = record
  strict private
    FValue: T;
    FLifetime: IInterface;
  public
    constructor Create(const AValue: T); overload;
    class operator Implicit(const AValue: T): TSmartPointer<T>;
    property Value: T read FValue;
  end;

Here it is in action, where TLifetimeWatcher is a little class that executes some code when it dies:

procedure UseIt;
var
  x: TSmartPointer<TLifetimeWatcher>;
begin
  x := TLifetimeWatcher.Create(procedure
  begin
    Writeln('I died.');
  end);
end;

Here's the full project code that defines TSmartPointer<>, TLifetimeWatcher, and the above test routine:

{$APPTYPE CONSOLE}

uses
  SysUtils;

type
  TLifetimeWatcher = class(TInterfacedObject)
  private
    FWhenDone: TProc;
  public
    constructor Create(const AWhenDone: TProc);
    destructor Destroy; override;
  end;

{ TLifetimeWatcher }

constructor TLifetimeWatcher.Create(const AWhenDone: TProc);
begin
  FWhenDone := AWhenDone;
end;

destructor TLifetimeWatcher.Destroy;
begin
  if Assigned(FWhenDone) then
    FWhenDone;
  inherited;
end;

type
  TSmartPointer<T: class> = record
  strict private
    FValue: T;
    FLifetime: IInterface;
  public
    constructor Create(const AValue: T); overload;
    class operator Implicit(const AValue: T): TSmartPointer<T>;
    property Value: T read FValue;
  end;

{ TSmartPointer<T> }

constructor TSmartPointer<T>.Create(const AValue: T);
begin
  FValue := AValue;
  FLifetime := TLifetimeWatcher.Create(procedure
  begin
    AValue.Free;
  end);
end;

class operator TSmartPointer<T>.Implicit(const AValue: T): TSmartPointer<T>;
begin
  Result := TSmartPointer<T>.Create(AValue);
end;

procedure UseIt;
var
  x: TSmartPointer<TLifetimeWatcher>;
begin
  x := TLifetimeWatcher.Create(procedure
  begin
    Writeln('I died.');
  end);
end;

begin
  try
    UseIt;
    Readln;
  except
    on E:Exception do
      Writeln(E.Classname, ': ', E.Message);
  end;
end.

Update: Fixed capture - was capturing a location in the structure, which of course will be freely copied etc.

10 comments:

Lars Fosdal said...

Does this allow you to reference count and "garbage collect" any pointer class?

Are there any limitations to this behaviour with regards to scope / referencing?

Malcolm Groves said...

Hi Barry,

Thanks for this, it taught me a few things I'd not thought of before, and actually gave me an idea on how to simplify some existing code. Nice one.

One question, why the call to valueLoc^.Free in the anonymous method? Given this method is kicked off from the destructor, why free it again?

Cheers
Malcolm

gabr said...

That is simply beautiful. I love it!

Barry Kelly said...

@Malcom - actually, it should be AValue.Free. valueLoc was initialized to a location in this individual structure, but of course the structure may be copied, and the original one destroyed, so valueLoc could point to e.g. stack memory. Capturing the AValue parameter is correct.

Re calling TObject.Free, the thing is that there are two objects involved here. The first one is the wrapped object, the second one is the lifetime watcher. Since the wrapped object need not implement an interface, we can't rely on interface reference counting to free it - and in any case, if we could, we could just use that mechanism for lifetime management.

So, apart from the wrapped object, there is also the lifetime watcher instance which fires off a delegate when it is freed. Because the lifetime watcher implements an interface, we can use reference counting to determine when it dies.

To recap, in the specific usage example, there are two instances of TLifetimeWatcher (I reused it to demonstrate lifetime ending). The first one is a reference-counted one that lives in the FLifetime reference of type IInterface. The second one is the wrapped object, and lives in the FValue field of type TLifetimeWatcher when the class is instantiated with T = TLifetimeWatcher.

When the FLifetime one gets its last _Release called, it frees the object - in the particular example, this is the other TLifetimeWatcher instance. This other one has a different method reference, one that prints out 'I died.'.

Barry Kelly said...

@Lars - Here are some limitations: you must always have at least one TSmartPointer<T> around for the instance to be freed. Since most APIs etc. will expect values of type T, not values of type TSmartPointer<T>, this means that probably there will be many "unprotected" values of type T around. As long as you keep the few that matter, though, you should be in good shape.

It may become annoying to have to use x.Value.Foo(42) rather than x.Foo(42), but without member lifting that is the best that can be done. Also, it would be troublesome in Delphi, because it does not have C++'s feature where there are different operators, '.' and '->', for member access on value types and pointers to value types respectively. This works well in C++ because you can't get ambiguities between operations on the smart pointer (a value type) and on the wrapped object (a reference type).

In Delphi, however, accessing members on the TSmartPointer<T> record and accessing members on the value of type T contained within it would, with automatic member lifting, result in possible ambiguities. For example, if x is of type TSmartPointer<TObject> and we had member lifting, calling x.Create would be ambiguous - do you mean TObject.Create or TSmartPointer<TObject>.Create?

Other limitations - apart from the usual caveats that apply to reference counting (cyclic references, some performance loss) and value types that have managed types (not blittable, you need to take care to _AddRef and _Release manually if you are doing odd things), there's nothing that stands out, no.

Michael said...

Does

constructor TLifetimeWatcher.Create(const AWhenDone: TProc);

not need a call to inherited; or similar?
Just curious as the appropriate .Destroy does this.

cu,
Michael

Barry Kelly said...

@Michael - there is nothing to initialize in the ancestor; TObject.Create doesn't do anything either. However, it is good practice in general to call inherited even in these cases, true.

Felix.Yeou said...

This is an anothor method to implement the "smart pointer", it likes the STL-auto_ptr,

here is the links:

http://www.cnblogs.com/felixYeou/archive/2008/09/06/1285806.html

i'm the author, want to be your friend:)

Fran├žois said...

Funny thing, I ran it from the IDE and I saw only 1 "I died" output, even though I expected 2 as we create 2 LifeTimeWatcher objects: 1 explictly in UseIt and the other as part of the SmartPointer...

And interestingly, if you set a break point in the TLifetimeWatcher.Create, on the line "FWhenDone := AWhenDone;"
you get a nice :
---------------------------
Debugger Exception Notification
---------------------------
Project Project1.exe raised exception class EInvalidPointer with message 'Invalid pointer operation'.

If you set it on the "begin" of TLifetimeWatcher.Create, you get a nice stack overflow...

But if, in TSmartPointer\T/.Create, you nil the AValue after freeing it, everything goes back as expected: {You only live and let die twice} ...couldn't resist ;-)

Barry Kelly said...

@Fran├žois:

1) The reason you only see 1 "I died" message is because it isn't TLifetimeWatcher that prints out "I died", but rather it is the method reference passed to it. Only one method reference which prints out "I died" is passed in the program, so there will only be one message.

2) There does appear to be a bug in the debugger, yes; the local variables window seems to be evaluating AWhenDone, which is of course a method call. This causes premature freeing of the object, which in turn makes the memory manager complain later.

3) The stack overflow you saw looks like a side-effect of the debugger bug above, but I haven't diagnosed it. Rest assured, the program behaves as expected at runtime!

4) Nil'ing AValue after freeing it will only prevent the duplicate freeing, it doesn't stop the premature freeing that the faulty debugger behaviour is creating.