Open the Delphi RTL source and you will find this pattern in many places:
if Assigned(X) then FreeAndNil(X);
FreeAndNil already copes with nil, so the “if” looks redundant. But there is a classic counter-argument: when X is mostly nil, the explicit test is faster, because it skips a procedure call. Opinions are cheap, so I measured it.
What really happens inside FreeAndNil
In Delphi 13, FreeAndNil is declared inline (System.SysUtils.pas). Its body copies the reference, sets the variable to nil, then calls Temp.Free. It does not test nil itself. TObject.Free is not inline, and its body is if Self <> nil then Destroy (System.pas).
So the difference between the two forms is exactly one call to TObject.Free:
- X is nil: the “if” skips the call to Free.
- X holds an object: the “if” adds one compare in front of the destructor.
The question is only: how much is that one call worth? And while we are at it: how much does FreeAndNil cost compared with a plain X.Free? And could FreeAndNil itself be written better, so the “if” is not needed at all?
Here is such a “better FreeAndNil”. It tests nil first, and then calls Destroy directly, because the nil test inside Free is no longer needed:
procedure FreeAndNilBetter(const [ref] Obj: TObject); inline; var Temp: TObject; begin if Obj = nil then Exit; Temp := Obj; TObject(Pointer(@Obj)^) := nil; Temp.Destroy; end;
The method
- An array of 4096 slots. One pass fills the slots (not timed), then frees every slot (timed).
- Three data patterns: every slot nil, every slot an object, and a 50% random mix. The mix draws a new pattern for every pass, so the branch predictor cannot learn it.
- The objects are plain TObject. That is the cheapest destructor there is, so it is the case most favourable to the extra “if”.
- One run = about 4 million calls. One warm-up run, then 5 runs; the table shows the fastest.
- Four ways to free:
FreeAndNil(X),if Assigned(X) then FreeAndNil(X),FreeAndNilBetter(X), and a plainX.Free(which leaves X dangling, so it is only a speed reference). - Before any timing, a self-check frees every pattern with a class that counts its destructor calls, and checks that every object was freed exactly once and every slot is nil (the X.Free way is excused from the nil check).
- Delphi 13, Release build, Win32 and Win64, AMD Ryzen 5 7600X.
The numbers
Nanoseconds per call, lower is better:
Win32 nil objects 50% mix RTL FreeAndNil 0.92 10.01 7.09 if Assigned + FreeAndNil 0.37 10.23 7.04 Better FreeAndNil 0.37 9.30 6.66 X.Free (no nil) 0.92 9.67 7.19 Win64 nil objects 50% mix RTL FreeAndNil 1.11 10.28 7.84 if Assigned + FreeAndNil 0.37 10.09 7.63 Better FreeAndNil 0.55 9.57 7.43 X.Free (no nil) 1.11 10.34 7.94
I ran the benchmark several times, and rebuilt it several times. Within one build, the “nil” column repeats almost exactly. But a rebuild can move a whole row by about 0.2 ns (the code lands at a different address), and the other columns move by up to about 1.3 ns between runs. So only the differences that kept their sign in every run and every build mean something.
What the numbers say
- The counter-argument is right. When X is nil, the “if” saves about 0.6-0.8 ns per call. In every run, on both platforms.
- But look at the size. That is about 3 milliseconds per 4 million nil variables. You need a very special loop before you notice it.
- FreeAndNil costs the same as Free. In this build the two match on both platforms (an earlier build showed FreeAndNil 0.2 ns slower on Win64; this build shows no difference). The safety of FreeAndNil is practically free.
- The better FreeAndNil gets the nil saving without the “if”. On Win32 it is as fast as the explicit “if”; on Win64 it was about 0.2 ns behind it. And with objects it is the fastest of all, about 0.5-0.8 ns per call, because it calls Destroy directly instead of going through Free. With it in the RTL, the “if” would become useless.
- When X holds an object, the “if” costs nothing I can measure. Even a bare TObject takes 10+ ns to destroy, and a real destructor takes much more. One compare disappears in that.
So the “if” in front of FreeAndNil is harmless. It is not needed for correctness, and it buys speed only in a loop that frees millions of mostly-nil variables.
Personally, I always liked the plain FreeAndNil(X) better: fewer lines of code, fewer brain cells needed to read it. But now I see that the “if” does bring a (tiny) improvement. 🙂
That being said, in a real application you free larger, more complex objects: they release resources, close forms, files and network connections. There, this (artificially) measured difference shrinks until it becomes invisible.
The code
The unit uses only RTL units, so it compiles in a stock Delphi. Call RunFreeAndNilBenchmark from a console program and print the result. Build it in Release mode: the unit sets {$O+,R-,Q-} itself, but a Debug memory manager would change the cost of freeing.
UNIT FreeAndNilBench.Core;
{=============================================================================================================
2026.10.11
www.GabrielMoraru.com
--------------------------------------------------------------------------------------------------------------
Measures four ways to free an object reference:
1. FreeAndNil(X) - the RTL's own (System.SysUtils)
2. if Assigned(X) then FreeAndNil(X) - the explicit nil test in front of it
3. FreeAndNilBetter(X) - a better FreeAndNil: tests nil itself, then calls Destroy
4. X.Free - frees, but leaves X dangling (no nil)
over three data patterns: all slots nil, all slots objects, 50% mix.
Method
- 4096 slots in a global static array (16 KB on Win32, 32 KB on Win64 for the array alone; the objects it points to come on top).
- One pass = fill the slots (NOT timed), then the timed loop that frees every slot.
- One run = PassesPerRun passes. Each (way, pattern) gets one warm-up run, then RunCount runs; the fastest run is reported.
- The 50% mix uses Random(2) with the same RandSeed at the start of every run, so all ways see the same sequence;
every pass draws a new pattern, so the branch predictor cannot learn it.
- The objects are plain TObject: the cheapest destructor there is, so the case most favourable to the extra "if".
Before timing, a self-check runs every way over every pattern with a class that counts its destructor calls.
Uses RTL units only: it compiles in a stock Delphi.
=============================================================================================================}
{$O+,R-,Q-}
INTERFACE
function RunFreeAndNilBenchmark: string;
IMPLEMENTATION
uses
Winapi.Windows, System.SysUtils, System.Diagnostics, System.Win.Registry;
CONST
SlotCount = 4096;
PassesPerRun = 1000; // 1000 * 4096 = 4,096,000 calls per run
RunCount = 5;
MixSeed = 20261011;
TYPE
TPattern = (patNil, patObjects, patMix);
TFreeAllProc = procedure;
TWay = record
Name : string;
FreeAll: TFreeAllProc;
SetsNil: Boolean; // FALSE for a way that leaves the slots dangling on purpose: the self-check then skips its nil test
end;
TCountedObject = class(TObject)
public
destructor Destroy; override;
end;
CONST
PatternName: array[TPattern] of string = ('nil', 'objects', '50% mix');
// Unit level on purpose: a static array is timed without pointer indirection. Not thread-safe.
VAR
Slots : array[0..SlotCount-1] of TObject;
DestroyCount: Integer;
{-------------------------------------------------------------------------------------------------------------
A better FreeAndNil: it tests nil itself, so the caller needs no "if Assigned", and calls Destroy instead of Free.
An inline routine must be defined above its callers, or the compiler does not expand it.
-------------------------------------------------------------------------------------------------------------}
procedure FreeAndNilBetter(const [ref] Obj: TObject); inline;
var
Temp: TObject;
begin
if Obj = nil then Exit;
Temp := Obj;
TObject(Pointer(@Obj)^) := nil;
Temp.Destroy;
end;
destructor TCountedObject.Destroy;
begin
Inc(DestroyCount);
inherited;
end;
{-------------------------------------------------------------------------------------------------------------
THE FOUR WAYS - each one is the complete timed loop
-------------------------------------------------------------------------------------------------------------}
procedure FreeAll_RtlFreeAndNil;
var
i: Integer;
begin
for i := 0 to High(Slots) do
FreeAndNil(Slots[i]);
end;
procedure FreeAll_AssignedFreeAndNil;
var
i: Integer;
begin
for i := 0 to High(Slots) do
if Assigned(Slots[i]) then FreeAndNil(Slots[i]);
end;
procedure FreeAll_BetterFreeAndNil;
var
i: Integer;
begin
for i := 0 to High(Slots) do
FreeAndNilBetter(Slots[i]);
end;
{ Leaves every freed slot dangling on purpose. Only FillSlots and ForgetSlots touch the slots afterwards, and both overwrite them without reading them. }
procedure FreeAll_Free;
var
i: Integer;
begin
for i := 0 to High(Slots) do
Slots[i].Free;
end;
function MakeWay(const Name: string; FreeAll: TFreeAllProc; SetsNil: Boolean): TWay;
begin
Result.Name := Name;
Result.FreeAll := FreeAll;
Result.SetsNil := SetsNil;
end;
{ The first way is the reference row of the difference table. }
function GetWays: TArray<TWay>;
begin
Result := [MakeWay('RTL FreeAndNil', FreeAll_RtlFreeAndNil, TRUE),
MakeWay('if Assigned + FreeAndNil', FreeAll_AssignedFreeAndNil, TRUE),
MakeWay('Better FreeAndNil', FreeAll_BetterFreeAndNil, TRUE),
MakeWay('X.Free (no nil)', FreeAll_Free, FALSE)];
end;
{-------------------------------------------------------------------------------------------------------------
FILL
-------------------------------------------------------------------------------------------------------------}
{ Fills every slot for the pattern, with instances of ObjClass. Returns the number of non-nil slots. }
function FillSlots(Pattern: TPattern; ObjClass: TClass): Integer;
var
i: Integer;
begin
Result := 0;
for i := 0 to High(Slots) do
begin
case Pattern of
patNil : Slots[i] := nil;
patObjects: Slots[i] := ObjClass.Create;
patMix : if Random(2) = 0
then Slots[i] := nil
else Slots[i] := ObjClass.Create;
end;
if Slots[i] <> nil then Inc(Result);
end;
end;
{ Forgets every slot without freeing it or reading it. Clears the dangling references that "X.Free (no nil)" leaves behind. }
procedure ForgetSlots;
var
i: Integer;
begin
for i := 0 to High(Slots) do
Slots[i] := nil;
end;
{-------------------------------------------------------------------------------------------------------------
SELF-CHECK
Returns '' when every way frees every object exactly once and, if the way sets nil (SetsNil), leaves every slot nil.
-------------------------------------------------------------------------------------------------------------}
function SelfCheck(const Ways: TArray<TWay>): string;
var
Way : TWay;
Pattern : TPattern;
i, Filled, NotNil, Destroyed: Integer;
begin
Result := '';
for Way in Ways do
for Pattern := Low(TPattern) to High(TPattern) do
begin
RandSeed := MixSeed;
Filled := FillSlots(Pattern, TCountedObject);
DestroyCount := 0;
Way.FreeAll;
Destroyed := DestroyCount;
if Way.SetsNil then
begin
NotNil := 0;
for i := 0 to High(Slots) do
if Slots[i] <> nil then Inc(NotNil);
if NotNil <> 0 then Result := Result + Format('SELF-CHECK FAILED: %s, %s: %d slot(s) not nil after freeing', [Way.Name, PatternName[Pattern], NotNil]) + sLineBreak;
end;
if Destroyed <> Filled then Result := Result + Format('SELF-CHECK FAILED: %s, %s: %d destructor call(s), expected %d', [Way.Name, PatternName[Pattern], Destroyed, Filled]) + sLineBreak;
ForgetSlots; // A way may leave dangling references behind (on purpose or by a bug)
end;
end;
{-------------------------------------------------------------------------------------------------------------
MEASURE
-------------------------------------------------------------------------------------------------------------}
{ One run: PassesPerRun passes. Returns the ticks spent in the freeing loop only. }
function TimeOneRun(const Way: TWay; Pattern: TPattern): Int64;
var
Pass : Integer;
Start: Int64;
begin
Result := 0;
RandSeed := MixSeed;
for Pass := 1 to PassesPerRun do
begin
FillSlots(Pattern, TObject); // Not timed. Overwrites the dangling references of the previous pass without reading them.
Start := TStopwatch.GetTimeStamp;
Way.FreeAll;
Result := Result + (TStopwatch.GetTimeStamp - Start);
end;
ForgetSlots; // Not timed. No dangling reference survives the run.
end;
{ One warm-up run, then RunCount runs. Returns the fastest run, in nanoseconds per call. }
function Measure(const Way: TWay; Pattern: TPattern): Double;
var
Run : Integer;
Ticks, Best: Int64;
begin
TimeOneRun(Way, Pattern); // Warm-up, not reported
Best := High(Int64);
for Run := 1 to RunCount do
begin
Ticks := TimeOneRun(Way, Pattern);
if Ticks < Best then Best := Ticks;
end;
Result := Best * 1E9 / TStopwatch.Frequency / (Int64(PassesPerRun) * SlotCount);
end;
{-------------------------------------------------------------------------------------------------------------
REPORT
-------------------------------------------------------------------------------------------------------------}
function GetCpuName: string;
var
Reg: TRegistry;
begin
Result := 'unknown';
Reg := TRegistry.Create(KEY_READ);
try
Reg.RootKey := HKEY_LOCAL_MACHINE;
if Reg.OpenKeyReadOnly('HARDWARE\DESCRIPTION\System\CentralProcessor\0')
and Reg.ValueExists('ProcessorNameString') then
Result := Trim(Reg.ReadString('ProcessorNameString'));
finally
FreeAndNil(Reg);
end;
end;
function PlatformName: string;
begin
{$IFDEF WIN64}
Result := 'Win64';
{$ELSE}
Result := 'Win32';
{$ENDIF}
end;
{ Formats a difference with an explicit sign, so +0.00 and -0.00 never look alike. }
function SignedNs(Value: Double): string;
begin
Result := FormatFloat('0.00', Abs(Value), TFormatSettings.Invariant);
if Value < 0
then Result := '-' + Result
else Result := '+' + Result;
end;
{ Difference in ns plus its percent of Reference, for example "-0.77 (-66%)". }
function SignedNsAndPercent(Value, Reference: Double): string;
var
Percent: Integer;
begin
if Reference > 0
then Percent := Round(Value * 100 / Reference)
else Percent := 0;
if Percent < 0
then Result := SignedNs(Value) + ' (' + IntToStr(Percent) + '%)'
else Result := SignedNs(Value) + ' (+' + IntToStr(Percent) + '%)';
end;
function RunFreeAndNilBenchmark: string;
const
RowLabelWidth = 26;
ColWidth = 10;
DiffColWidth = 16;
var
Ways : TArray<TWay>;
Ns : array of array[TPattern] of Double;
Failed, Line: string;
w : Integer;
Pattern : TPattern;
SB : TStringBuilder;
function HeaderRow(Width: Integer): string;
var
P: TPattern;
begin
Result := StringOfChar(' ', RowLabelWidth);
for P := Low(TPattern) to High(TPattern) do
Result := Result + PatternName[P].PadLeft(Width);
end;
begin
TStopwatch.StartNew; // Sets TStopwatch.Frequency: GetTimeStamp and Frequency do not initialize the class themselves; only Create and StartNew do
Ways := GetWays;
SB := TStringBuilder.Create;
try
SB.AppendLine('FreeAndNil benchmark - ns per call, lower is better');
SB.AppendLine(Format('CompilerVersion: %s RTLVersion: %s', [FormatFloat('0.0', CompilerVersion, TFormatSettings.Invariant), FormatFloat('0.0', RTLVersion, TFormatSettings.Invariant)]));
SB.AppendLine('Platform: ' + PlatformName);
{$IFDEF DEBUG}
SB.AppendLine('Build: Debug');
{$ELSE}
SB.AppendLine('Build: Release');
{$ENDIF}
SB.AppendLine('CPU: ' + GetCpuName);
SB.AppendLine('Date: ' + FormatDateTime('yyyy-mm-dd hh:nn', Now, TFormatSettings.Invariant));
SB.AppendLine(Format('Slots: %d Passes per run: %d Calls per run: %d Runs: %d (+1 warm-up)', [SlotCount, PassesPerRun, PassesPerRun * SlotCount, RunCount]));
Failed := SelfCheck(Ways);
if Failed <> '' then
begin
SB.Append(Failed);
SB.AppendLine('Benchmark not run.');
EXIT(SB.ToString);
end;
SB.AppendLine('Self-check: OK');
SB.AppendLine;
SetLength(Ns, Length(Ways));
for Pattern := Low(TPattern) to High(TPattern) do
for w := 0 to High(Ways) do
Ns[w][Pattern] := Measure(Ways[w], Pattern);
SB.AppendLine('Time per call (ns)');
SB.AppendLine(HeaderRow(ColWidth));
for w := 0 to High(Ways) do
begin
Line := Ways[w].Name.PadRight(RowLabelWidth);
for Pattern := Low(TPattern) to High(TPattern) do
Line := Line + FormatFloat('0.00', Ns[w][Pattern], TFormatSettings.Invariant).PadLeft(ColWidth);
SB.AppendLine(Line);
end;
SB.AppendLine;
SB.AppendLine('Difference to "RTL FreeAndNil" (ns per call, and percent of the RTL FreeAndNil time)');
SB.AppendLine(HeaderRow(DiffColWidth));
for w := 0 to High(Ways) do
begin
Line := Ways[w].Name.PadRight(RowLabelWidth);
for Pattern := Low(TPattern) to High(TPattern) do
Line := Line + SignedNsAndPercent(Ns[w][Pattern] - Ns[0][Pattern], Ns[0][Pattern]).PadLeft(DiffColWidth);
SB.AppendLine(Line);
end;
SB.AppendLine;
SB.AppendLine(Format('Objects: plain TObject. Timed part: only the freeing loop. Reported: the fastest of %d runs.', [RunCount]));
Result := SB.ToString;
finally
FreeAndNil(SB);
end;
end;
end.