Author Topic: Lifetime of variables captured by closures (reference to) in FPC  (Read 448 times)

andersonscinfo

  • Full Member
  • ***
  • Posts: 159
I am trying to fully understand closure semantics in FPC and I keep doubting my mental model.

Situation: when a nested local procedure captures an outer local variable (the compiler emits a closure for a `reference to`), is the captured variable captured by reference or by value?

Example:
```pascal
function MakeCounter: TFunc;
var
  Count: Integer = 0;
begin
  Result := function: Integer
    begin
      Inc(Count);
      Result := Count;
    end;
end;
```

In Delphi, the closure captures Count by reference, and Count lives as long as the closure exists (managed lifetime). I believe FPC does the same, but I am not 100% sure about two things:

1. Is the capture by reference (shared with the outer local) or a per-closure copy?
2. After the enclosing procedure returns, does the captured local stay alive for as long as the closure is referenced, or can it become a dangling reference?

What I have already tried: I tested the example above in FPC 3.2.2 and the counter incremented across calls, which suggests by-reference capture, but I would like the exact lifetime rules spelled out, and how they differ from Delphi if at all.

My question: what are the precise rules for lifetime and capture mode of captured locals in FPC closures?

andersonscinfo

  • Full Member
  • ***
  • Posts: 159
Lifetime of variables captured by closures (reference to) in FPC
« Reply #1 on: August 25, 2026, 03:23:47 am »
I am trying to fully understand closure semantics in FPC and I keep doubting my mental model.

Situation: when a nested local procedure captures an outer local variable (the compiler emits a closure for a reference to), is the captured variable captured by reference or by value?

Example:
[pascal]
function MakeCounter: TIntFunc;
var
  Count: Integer = 0;
begin
  Result := function: Integer
    begin
      Inc(Count);
      Result := Count;
    end;
end;
[/pascal]

In Delphi, the closure captures Count by reference, and Count lives as long as the closure exists (managed lifetime). I believe FPC does the same, but I am not 100% sure about two things:

1. Is the capture by reference (shared with the outer local) or a per-closure copy?
2. After the enclosing procedure returns, does the captured local stay alive for as long as the closure is referenced, or can it become a dangling reference?

My question: what are the precise rules for lifetime and capture mode of captured locals in FPC closures?

(Note: I initially wrote that I had tested this in FPC 3.2.2 — that was incorrect, I have not actually run this code yet. Removing that claim.)
« Last Edit: August 25, 2026, 03:34:25 am by andersonscinfo »

ALLIGATOR

  • Sr. Member
  • ****
  • Posts: 476
  • FPC git[main] Lazarus git[main] 💪🐯💪
Re: Lifetime of variables captured by closures (reference to) in FPC
« Reply #2 on: August 25, 2026, 03:50:04 am »
Your code literally turns into this:

Code: Pascal  [Select][+][-]
  1. type
  2.   TAnonymousInterfacedObject = class(TInterfacedObject, IInvokable)
  3.     FCount: Integer;
  4.     function method1: Integer;
  5.   end;
  6.  
  7. function TAnonymousInterfacedObject.method1: Integer;
  8. begin
  9.   Inc(FCount);
  10.   Result := FCount;
  11. end;
  12.  
  13. function MakeCounter: TFunc;
  14. var
  15.   Anon: TAnonymousInterfacedObject;
  16. begin
  17.   Anon := TAnonymousInterfacedObject.Create;
  18.   Anon.FCount := 0;
  19.   Anon.method1;
  20. end;
  21.  

Thaddy

  • Hero Member
  • *****
  • Posts: 19805
  • Glad to be alive.
Re: Lifetime of variables captured by closures (reference to) in FPC
« Reply #3 on: August 25, 2026, 08:23:28 am »
The behavior is the same as delphi with three exception: when counting over Invoke, you need to add (). And Delphi needs explicit creation with as.
Delphi also demands a GUID on extending TFunc. Freepascal doesn't strictly need that unless you want to use as.
Demo
Code: Pascal  [Select][+][-]
  1. {$ifdef fpc}{$mode delphi}{$endif}
  2. // this code compiles in both modern delphi and freepascal trunk
  3. // shows how to avoid small syntactical differences between the two
  4. // this code is accepted by both compilers
  5. type
  6.  
  7.   TFunc = reference to function:integer; // Actually a COM interface in both dialects
  8.    // extends TFunc with a setter, also triggered by Invoke.
  9.   ICount = interface(TFunc)
  10.   ['{472C4D07-1A7E-4D75-8A87-46417BCA8008}']      
  11.     Procedure Invoke(const Value:integer);overload;
  12.   end;
  13.  
  14.   TCounter = Class(TInterfacedObject,ICount)
  15.   private
  16.     FValue:integer;
  17.   public
  18.     function Invoke:integer;overload;
  19.     procedure Invoke(const value:integer);overload;
  20.   end;
  21.  
  22.  
  23.   function TCounter.Invoke:integer;
  24.   begin
  25.     Result := FValue;
  26.     inc(FValue);
  27.   end;
  28.  
  29.   procedure TCounter.Invoke(const value:integer);
  30.   begin
  31.     FValue := Value;
  32.   end;
  33.  
  34. var
  35.   Count:ICount;
  36.   i:integer;
  37. begin
  38.   Count := TCounter.Create as ICount;// delphi demands this, you can drop as in Freepascal.
  39.   Count(100); // starts from 100, invokes setter, opaque
  40.   writeln(Count());//100, invokes getter, opaque
  41.   writeln(Count());//101, Freepascal needs this syntax, in Delphi () is optional
  42.   Count(1000);// starts from 1000
  43.   writeln(Count());// 1000
  44.   writeln(Count());// 1001
  45.   count(-9);
  46.   for i := 0 to 9 do write(count():3);
  47.   readln;
  48. end.
In this example the lifetime is equal to the lifetime of the program.
Yes, it is by reference and yes this is Delphi compatible, but in Delphi you can drop the () - but you are allowed to use them, so you can write compatible code. Freepascal demands them.
Another small difference is the create: Delphi demands Count := TCounter.Create as ICount; but in free Pascal the as part is optional. Again: you can still write compatible code.
The above code tested with Delphi 13 CE and current trunk without change.
You just need to be aware of these slight quirks between the dialects.
« Last Edit: August 25, 2026, 05:28:14 pm by Thaddy »
Any "programmer" that knows only one programming language is not a programmer

Thaddy

  • Hero Member
  • *****
  • Posts: 19805
  • Glad to be alive.
Re: Lifetime of variables captured by closures (reference to) in FPC
« Reply #4 on: August 25, 2026, 08:40:10 am »
@ALLIGATOR yes, you can do that too, with the exception that your code does not use the opaque invoke, which is quite the reason of function references. My demo shows invoke to be triggered both as getter and setter and 100% cross-compatible.
Your code is still useful for 3.2.2, though, but you must explicitly call method1.
My code also works on 3.2.2, but you must implement the invoke function in the interface and comment out the TFunc and call invoke explicitly, like you do,  too:
This example stays Delphi compatible and is for 3.2.x:
Code: Pascal  [Select][+][-]
  1. program counter;
  2. // this is for freepascal before function references were supported
  3. // invoke must be expicitly called, like in ALLIGATOR'S code.
  4. // it stays Delphi compatible.
  5. {$ifdef fpc}{$mode delphi}{$endif}
  6. type
  7.   ICount = interface // can't derive from TFunc here
  8.   ['{472C4D07-1A7E-4D75-8A87-46417BCA8008}']      
  9.     Function Invoke:Integer;overload; // Must be added to interface
  10.     Procedure Invoke(const Value:integer);overload;
  11.   end;
  12.  
  13.   TCounter = Class(TInterfacedObject,ICount)
  14.   private
  15.     FValue:integer;
  16.   public
  17.     function Invoke:integer;overload;
  18.     procedure Invoke(const value:integer);overload;
  19.   end;
  20.  
  21.  
  22.   function TCounter.Invoke:integer;
  23.   begin
  24.     Result := FValue;
  25.     inc(FValue);
  26.   end;
  27.  
  28.   procedure TCounter.Invoke(const value:integer);
  29.   begin
  30.     FValue := Value;
  31.   end;
  32.  
  33. var
  34.   Count:ICount;
  35.   i:integer;
  36. begin
  37.   // cumbersome explicit calls, but still useful.
  38.   Count := TCounter.Create as ICount;
  39.   Count.Invoke(100); // explicitly calls invoke, starts from 100
  40.   writeln(Count.Invoke());//100 // explict call
  41.   writeln(Count.Invoke());//101 // explicit call
  42.   Count.Invoke(1000);// explicit call, starts from 1000
  43.   writeln(Count.Invoke());// 1000
  44.   writeln(Count.Invoke());//1001
  45.   count.Invoke(-9);
  46.   for i := 0 to 9 do write(count.Invoke():3);
  47.   readln;
  48. end.
https://forum.lazarus.freepascal.org/index.php/topic,74842.msg589230.html#msg589230
Examine the difference and see why function references can be very useful.
Same technique, different, longer,  notation....

I did not even use anonymous functions in my first example, because that follows from invoke() in trunk and delphi.
I hope it documents the variable capture a bit more clearly.
« Last Edit: August 25, 2026, 12:16:01 pm by Thaddy »
Any "programmer" that knows only one programming language is not a programmer

andersonscinfo

  • Full Member
  • ***
  • Posts: 159
Re: Lifetime of variables captured by closures (reference to) in FPC
« Reply #5 on: August 25, 2026, 01:18:32 pm »
Thank you both for the detailed explanations — that clears up my mental model.

So to confirm my understanding:

1. **Capture is by reference**, not a per-closure copy. The captured local is shared with the outer scope, and it lives as long as the closure (interface) is referenced — same managed lifetime as Delphi. No dangling reference as long as the closure is alive.

2. The compiler rewrites the closure into an anonymous interfaced object holding the captured variable as a field (as ALLIGATOR showed), which is exactly why the lifetime is tied to the interface reference.

3. The main dialect quirks to be aware of:
   - FPC requires `()` on the opaque invoke; Delphi makes it optional.
   - Delphi demands `as` when creating the implementing class; FPC makes it optional.
   - Extending `TFunc` with a GUID is only needed if you want to use `as`.

I'll keep the `
Code: Pascal  [Select][+][-]
  1. ` blocks and the explicit `()` in mind for cross-compatible code. Thanks again for the concrete demos — the invoke-as-getter/setter pattern is a nice trick.

Thaddy

  • Hero Member
  • *****
  • Posts: 19805
  • Glad to be alive.
Re: Lifetime of variables captured by closures (reference to) in FPC
« Reply #6 on: August 25, 2026, 02:38:01 pm »
Exactly.
BTW: you can overload Invoke as much as you like.
It is not a trick, it is the consequence of function references and anonymous methods.
I learned what you call a "trick" from how it is used in Spring4D. (which is not quite Freepascal compatible yet, btw). So Stefan Glienke is my original source.

Sorting out the quirks is done by me.
(A.I. fails there: you can try for your self: all models fail, anyway IF I use it, I document it, and this was way before A.I. became mainstream)
« Last Edit: August 25, 2026, 02:45:47 pm by Thaddy »
Any "programmer" that knows only one programming language is not a programmer

PascalDragon

  • Hero Member
  • *****
  • Posts: 6430
  • Compiler Developer
Re: Lifetime of variables captured by closures (reference to) in FPC
« Reply #7 on: August 29, 2026, 04:10:46 pm »
1. Is the capture by reference (shared with the outer local) or a per-closure copy?
2. After the enclosing procedure returns, does the captured local stay alive for as long as the closure is referenced, or can it become a dangling reference?

Variables are captured by reference and the life time is at least as long as the function reference is alive as multiple function references inside the same function share the same capturer, thus the lifetime will be until the last function reference goes out of scope.

 

TinyPortal © 2005-2018