How to Manage Pascal Resources with Automatic Cleanup
Object Pascal has no garbage collector, so every object a routine creates needs a matching Free, and every acquired lock needs a matching release. The standard tool is try/finally. It is correct, but each additional resource adds another nesting level, and an acquire/release pair has to be repeated by every caller.
Sphere 10 Pascal Framework offers two managed records that move this bookkeeping into the compiler's own scope finalization. TDisposables owns a group of objects and frees them in reverse order when the routine exits. TScope carries one deferred cleanup call, such as a lock release, as a value that can be returned to a caller. This article explains what each one does, when it runs and what it expects from you.
1. Cleanup with nested try/finally
The sample routine uses three objects with a dependency between them. A writer borrows an output stream, so the writer must be destroyed before the stream. Written conventionally, each object gets its own try/finally, and the nesting order encodes the destruction order.
function CopyConventional(const AText, APrefix: String): String;
var
Source: TSourceLines;
Target: TTargetStream;
Writer: TLineWriter;
begin
Source := TSourceLines.Create;
try
Source.Text := AText;
Target := TTargetStream.Create;
try
Writer := TLineWriter.Create(Target, APrefix);
try
Writer.WriteLines(Source);
Result := Target.DataString;
finally
Writer.Free;
end;
finally
Target.Free;
end;
finally
Source.Free;
end;
end;
This code is correct. Each try begins only after its object was constructed, so a constructor that raises leaves nothing to leak.
2. Register ownership as each resource arrives
With TDisposables, the routine declares one bag and registers each object as it is created. AddObject records the object and returns it, so construction and registration share one statement.
function CopyAutomatic(const AText, APrefix: String): String;
var
Resources: TDisposables;
Source: TSourceLines;
Target: TTargetStream;
Writer: TLineWriter;
begin
Source := Resources.AddObject(TSourceLines.Create) as TSourceLines;
Source.Text := AText;
Target := Resources.AddObject(TTargetStream.Create) as TTargetStream;
Writer := Resources.AddObject(TLineWriter.Create(Target, APrefix)) as TLineWriter;
Writer.WriteLines(Source);
Result := Target.DataString;
end;
The behaviour that replaces the nested blocks is as follows.
-
Reverse order. The bag frees its entries from last registered to first. Registering an object after the things it depends on is enough to destroy it before them.
-
Partial acquisition. If a constructor raises,
AddObjectis never reached for that object, and the bag frees only what was registered before it. If a later statement raises, everything registered so far is freed. -
Cleanup failures. If a destructor raises, the bag still frees the remaining entries and then re-raises the first exception.
-
Ownership transfer.
RemoveObjecttakes an object back out of the bag, which leaves the caller responsible for freeing it. Use it when an object must outlive the routine.
The bag also manages raw memory. GetMem allocates a block and registers it, AddMem registers an existing block, ReallocMem resizes a registered block, and FreeMem or RemoveMem releases or withdraws one early.
Diagram source
---
config:
sequence:
actorMargin: 8
width: 96
mirrorActors: false
messageMargin: 28
noteMargin: 8
---
sequenceDiagram
participant R as Routine
participant B as TDisposables
R->>B: AddObject(Source)
R->>B: AddObject(Target)
R->>B: AddObject(Writer)
Note over R: returns or raises
Note over B: local is finalized
B->>B: Free Writer
B->>B: Free Target
B->>B: Free Source
3. How scope-based cleanup works
Delphi and Free Pascal finalize managed local variables when a routine exits, whether it returns normally or raises. Both records use that guarantee. Each holds a private reference-counted guardian, and the guardian's destructor performs the cleanup. No try/finally is written by hand because the compiler already emits one for the managed local.
| Type | Unit | Owns | Cleanup runs when |
TDisposables |
UMemory |
Any number of objects and memory blocks | The record's routine or owning object ends |
TScope |
UCommon |
One deferred object method | The last copy of the record ends, or is Reset |
4. Return the lock acquisition to its caller
TScope solves a different problem. An acquire/release pair normally forces every caller to write the same try/finally. A function that returns a TScope performs the acquisition and hands back the obligation to release it as a value. The Framework's TReadWriteLock exposes its three lock modes this way through EnterReadScope, EnterWriteScope and EnterUpgradeableReadScope. The two methods below do the same work.
procedure TGuardedValue.SetValueConventional(AValue: Integer);
begin
FGate.AcquireWriteLock;
try
FValue := AValue;
finally
FGate.ReleaseWriteLock;
end;
end;
procedure TGuardedValue.SetValue(AValue: Integer);
var
Scope: TScope;
begin
Scope := FGate.EnterWriteScope;
FValue := AValue;
end;
EnterWriteScope acquires the lock and returns a scope already bound to ReleaseWriteLock. The caller keeps the result in a local variable, and the lock is released when that local is finalized.
You can build the same kind of API for your own acquire/release pairs. TScope.Create takes a parameterless object method and stores it without calling it. The Framework's own implementation of EnterWriteScope shows the pattern, including the release that is needed if creating the scope itself fails.
function TReadWriteLock.EnterWriteScope: TScope;
var Scope: TScope;
begin
AcquireWriteLock;
try
Scope := TScope.Create(ReleaseWriteLock);
except
ReleaseWriteLock;
raise;
end;
Result := Scope;
end;
A TScope can be assigned and returned freely. All copies share one cleanup call, which runs when the last copy ends. Reset drops one copy's share early, and IsEmpty reports whether a variable currently holds a scope.
Diagram source
---
config:
sequence:
actorMargin: 8
width: 96
mirrorActors: false
messageMargin: 28
noteMargin: 8
---
sequenceDiagram
participant C as Caller
participant L as TReadWriteLock
C->>L: EnterWriteScope
L->>L: AcquireWriteLock
L-->>C: TScope
Note over C: protected work
Note over C: last copy finalized
C->>L: ReleaseWriteLock
5. Keep the two lifetime contracts distinct
The two records look similar but have opposite rules about copying.
| Rule | TDisposables |
TScope |
| Copy or return it | No. Its guardian points back to the record, so keep it at a stable address such as a routine local or a class field. | Yes. Copies share one cleanup. |
| End it early | RemoveObject or RemoveMem withdraws a single entry. |
Reset drops the current copy. |
| What must outlive it | Nothing. It owns its entries. | The object whose method it will call. |
begin/end block does not end a managed local's lifetime. Use a helper routine or call Reset when a scope must end before its routine does.
A plain try/finally remains the simplest choice for a single short-lived object. A disposal bag pays off when several objects with dependencies would otherwise nest, and a returned scope pays off when an API can package an acquisition together with its release.
6. Get the Framework and run the example
Get Sphere 10 Pascal Framework and follow its package instructions . You can use the units directly or build the optional Lazarus or Delphi runtime package.
The sample runs both routine pairs, forces a failure during construction and during processing, and checks that every object was freed in the expected order. It was compiled and run on Windows with Free Pascal 3.2.0 against
source revision
2cc6558
. Its Build.ps1 accepts the Framework, compiler and output paths.
Download the complete runnable sample.
For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .
Author: Herman Schoenfeld