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.

Resources retained within a deliberate cleanup boundary.
Resources retained within a deliberate cleanup boundary.
💡 In short. Use TDisposables to free a group of objects in reverse order at scope exit, and TScope to return a lock acquisition with its release attached.

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, AddObject is 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. RemoveObject takes 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.
ℹ️ A lock scope releases on whichever thread finalizes its last copy, so keep every copy on the acquiring thread and finish them before freeing the lock. Under Free Pascal a nested 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.

sample.zip
Download the complete worked example and build instructions (ZIP).

For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .

Author: Herman Schoenfeld