if Assigned(X) then FreeAndNil(X): is the check faster? I measured it

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 plain X.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.

Leave a Comment

Scroll to Top