Deterministic Cleanup in Delphi Without Heap Allocation: Custom Managed Records
Delphi 10.4+ Custom Managed Records provide RAII for value types via Initialize, Finalize, and Assign operators. Learn how to implement deterministic cleanup without heap allocation or interface overhead.
14 Mar 2026, 06:46 UTC

The Problem: Resource Leaks in Value Types
Delphi developers have long relied on try/finally blocks to release OS handles, GPU resources, or database connections wrapped in records. While effective, this pattern clutters the business logic, and a single forgotten finally block leads to a resource leak.
Interfaces provide deterministic finalization via reference counting, but they require heap allocation and virtual dispatch. For high-performance code or simple handle wrappers, this overhead is often unacceptable. Since Delphi 10.4 Sydney, Custom Managed Records allow records to have a deterministic lifecycle without leaving the stack. By implementing three special operators—Initialize, Finalize, and Assign—you can implement RAII (Resource Acquisition Is Initialization) patterns for value types.
The Lifecycle Operators
The compiler automatically injects calls to these operators at specific points in the program execution:
- Initialize: Executed when a record variable enters scope or is passed as a value parameter. Use this to set handles to a known null or sentinel state.
- Finalize: Executed when the variable leaves scope, whether via normal exit, an
Exitstatement, or an exception unwinding the stack. This is where resource release logic resides. - Assign: Triggered during a
:=assignment between two managed records. The compiler callsAssignon the destination before callingFinalizeon the source.
Crucially, for var or out parameters, the compiler only invokes Initialize and Finalize; Assign is skipped. This distinction is critical to avoid double-free errors.
Worked Example: Win32 Handle Wrapper
This example wraps a Windows HANDLE. It ensures CloseHandle is called exactly once, even if an exception occurs. This requires Delphi 10.4 or later.
unit HandleWrapper;
interface
uses
Winapi.Windows;
type
TAutoHandle = record
private
FHandle: THandle;
function IsValid: Boolean; inline;
public
class operator Initialize(out Dest: TAutoHandle);
class operator Finalize(var Dest: TAutoHandle);
class operator Assign(var Dest: TAutoHandle; const [ref] Src: TAutoHandle);
class function Create(AHandle: THandle): TAutoHandle; static;
property Handle: THandle read FHandle;
end;
implementation
class operator TAutoHandle.Initialize(out Dest: TAutoHandle);
begin
Dest.FHandle := INVALID_HANDLE_VALUE;
end;
class operator TAutoHandle.Finalize(var Dest: TAutoHandle);
begin
if Dest.IsValid then
CloseHandle(Dest.FHandle);
Dest.FHandle := INVALID_HANDLE_VALUE;
end;
class operator TAutoHandle.Assign(var Dest: TAutoHandle; const [ref] Src: TAutoHandle);
begin
// Release existing resource in destination before taking ownership of source
if Dest.IsValid then
CloseHandle(Dest.FHandle);
Dest.FHandle := Src.FHandle;
// Note: Src.FHandle cannot be modified here due to const [ref]
end;
class function TAutoHandle.Create(AHandle: THandle): TAutoHandle;
begin
Result.FHandle := AHandle;
end;
function TAutoHandle.IsValid: Boolean;
begin
Result := (FHandle <> INVALID_HANDLE_VALUE) and (FHandle <> 0);
end;
end.
Handling Exceptions
Consider a scenario where a file handle is opened, but a failure occurs during processing:
procedure ProcessFile;
var
H: TAutoHandle;
begin
H := TAutoHandle.Create(CreateFile('data.bin', GENERIC_READ, 0, nil, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0));
if not H.IsValid then
RaiseLastOSError;
// If this raises an exception, Finalize is still called by the compiler
PerformRiskyOperation(H.Handle);
end;
In this case, the developer does not need a try/finally block. The compiler-generated epilogue ensures TAutoHandle.Finalize is called during the stack unwind.
Trade-offs and Limitations
| Feature | Managed Record | Interface/Class |
|---|---|---|
| Allocation | Stack (Zero Heap) | Heap Required |
| Dispatch | Direct/Inline Call | Virtual Dispatch |
| Reference Counting | None | Automatic |
| RTTI/Published | Not Supported | Supported |
| Generic Impact | Binary bloat per type | Shared vtable |
Circular References: Unlike interfaces, managed records do not have a cycle collector. If two managed records hold references to each other via object fields, they will not be cleaned up automatically in a way that resolves the cycle.
The Assign Trap: Because Assign is called before the source is finalized, you must ensure the destination's current resource is released first. Failure to do so in the Assign operator results in a leak of the destination's previous handle.
Verification and Validation
To verify the behavior of a custom managed record, follow these steps in RAD Studio 11+:
- Log Execution: Insert
OutputDebugString('Finalize Called')inside theFinalizeoperator. Run the application and monitor the Event Log to confirm it fires on both normal exit and exception paths. - Inspect Assembly: Use
View → CPU Windows → Disassembly. Confirm that the calls toInitializeandFinalizeare directCALLinstructions rather than interface thunks. - Memory Leak Check: Run the binary under FastMM4 in
FullDebugMode. Verify that no memory leaks are reported, as the record itself should not allocate heap memory. - Binary Size: For generic managed records, check the MAP file. Each instantiation of a generic managed record generates its own operator code, which can increase binary size compared to a class-based approach.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.