Are const parameters dangerous?
Marco Cantu's recent blog post The Case of Delphi Const String Parameters opened a can of worms.
A very, very old can...
The presented behavior is not something new, and it is certainly not a compiler bug.
It is just the way reference counting works. A pure ARC problem—one instance of
something is destroyed when the other one is created and assigned, because the const
parameter did not trigger the reference counting mechanism—this is why const
is
used for speed optimization in the first place.
The same thing that happens with strings happens with reference-counted objects, but it is easier to see what happens with objects in the same kind of code. You will need a form with a memo and a button to test the following code:
type
IFoo = interface
function GetCount: Integer;
function GetNumber: Integer;
end;
TFoo = class(TInterfacedObject, IFoo)
private
FNumber: Integer;
public
constructor Create(ANumber: Integer);
destructor Destroy; override;
function GetCount: Integer;
function GetNumber: Integer;
end;
constructor TFoo.Create(ANumber: Integer);
begin
inherited Create;
FNumber := ANumber;
Form1.Memo1.Lines.Add('Created ' + FNumber.ToString);
end;
destructor TFoo.Destroy;
begin
Form1.Memo1.Lines.Add('Destroyed ' + FNumber.ToString);
inherited;
end;
function TFoo.GetCount: Integer;
begin
Result := FRefCount;
end;
function TFoo.GetNumber: Integer;
begin
Result := FNumber;
end;
procedure TForm1.Button1Click(Sender: TObject);
var
s1: IFoo;
procedure Test(const Value: IFoo);
begin
s1 := TFoo.Create(456);
Memo1.Lines.Add('Test Value ' + Value.GetNumber.ToString);
end;
begin
s1 := TFoo.Create(123);
Test(s1);
end;
Output will be:
Created 123
Created 456
Destroyed 123
Test Value 123
Destroyed 456
It is pretty obvious that the object instance holding 123 is destroyed before its
value is printed. Value.GetNumber.ToString
is accessing a dangling pointer.
And with dangling pointers, anything can happen—if the memory previously occupied by the object or string has not been reused, you may get the expected value even though that instance is dead, but if the memory is reused for some other reference type in the meantime, you will read the contents of some other object. Also, the whole thing might crash, or it might not. Basically, you get undefined behavior.
Analysis
A reference type consists of two parts: a variable that holds a pointer (address) to the actual memory location where the content (data) is located.
Managed reference type instances—ones that use ARC for managing their lifetime—are alive and point to valid memory as long as there is at least one strong reference to them. At the moment there are no more strong references, the memory allocated for that instance is released.
In theory, reference-counted variables should have only two states: either nil
(no associated data), or pointing to valid instance memory. However, under some
circumstances, it is possible to get weak references that can turn into dangling
pointers if the last strong reference to the instance is cleared in the meantime.
Such variables (references) represent non-zeroed weak references, and they will not be enough to keep the reference-counted instance alive, so they will still hold an address pointing to the now-invalid memory after the instance is destroyed.
This includes using raw pointer variables, using variables with the [unsafe]
attribute, and using function (procedure, method) const
parameters that don't trigger
the reference counting mechanism, where the reference itself is passed as a value
(copied).
The following picture shows what happens with reference-counted object instances (the same applies to strings and dynamic arrays) in the above code:
The left side shows what happens if a value parameter is used in the Test
procedure,
where reference counting is triggered and such parameter represents a strong
reference, while the right side shows what happens when a const
parameter is used in
the Test
procedure.
After executing the line
s1 := TFoo.Create(456);
a new object instance holding 456 will be created and allocated in a different memory location from the one holding 123.
If the Value
parameter is a strong reference (on the left), then it will still
point to the valid object instance in memory, and this instance will be destroyed
only after the Value
parameter goes out of scope at the end of the Test
procedure.
However, if the Value
is a weak reference (on the right), then after assignment
of the new object to the original and only strong reference s1
, the object instance
holding 123 will be destroyed, and Value
will no longer point to the valid data.
Solutions
There are several solutions to the above issue: declaring Value
as a value
parameter (not using const
), creating another strong reference using the local
variable in Test
procedure before the assignment to s1
destroys the original, or
creating another strong reference to s1
before the Test
procedure is called.
1. value parameter
procedure TForm1.Button1Click(Sender: TObject);
var
s1: IFoo;
procedure Test(Value: IFoo);
begin
s1 := TFoo.Create(456);
Memo1.Lines.Add('Test Value ' + Value.GetNumber.ToString);
end;
begin
s1 := TFoo.Create(123);
Test(s1);
end;
2. strong reference in Test
procedure TForm1.Button1Click(Sender: TObject);
var
s1: IFoo;
procedure Test(Value: IFoo);
var
s2: IFoo;
begin
s2 := Value; // or s2 := s1;
s1 := TFoo.Create(456);
Memo1.Lines.Add('Test Value ' + Value.GetNumber.ToString);
end;
begin
s1 := TFoo.Create(123);
Test(s1);
end;
3. strong reference before calling Test
procedure TForm1.Button1Click(Sender: TObject);
var
s1, s2: IFoo;
procedure Test(Value: IFoo);
begin
s1 := TFoo.Create(456);
Memo1.Lines.Add('Test Value ' + Value.GetNumber.ToString);
end;
begin
s1 := TFoo.Create(123);
s2 := s1;
Test(s1);
end;
From the above examples, not using const
parameters seems like the cleanest and
most obvious solution. Which often leads to the wrong conclusion: const
parameters
are dangerous and should not be used.
Are const parameters dangerous?
To answer this question, we have to go back to the beginning. Issues caused by accessing dangling pointers can be hard to track down. They are not commonly encountered when using object instances. However, the problem can more often be encountered when using strings, for several reasons.
First, strings in Delphi hold an almost magical aura, and they are often treated as
almost indestructible entities as long as they can be referenced. It is often
forgotten that they are just reference-counted instances, and that they can cease to exist
when there are no strong references to them. It is often forgotten that a string
field can be gone when its owning object instance is destroyed, and it is even
more often forgotten that using a const
parameter with a string will not keep that string
alive. Also, strings are commonly processed, parsed, modified and shuffled around,
and it is easy to overlook that some of the procedures used in that processing can
destroy the original data.
Because of all that, when developers encounter issues with strings for the first
time, they not only easily come to the conclusion that using const
parameters
is dangerous and should be avoided in their code, they also want to fix that at
large, since the RTL uses const
string parameters almost everywhere. This results
in bug reports pointing to flaws in the compiler or the RTL that are rightfully
closed "As designed", which makes the whole situation even worse. You
have a frustrated developer that wasted some time chasing bugs that are then not
recognized as bugs.
The original purpose of Marco's blog post is to draw attention to the potential
issues and make developers more aware of them, while at the same time saying that
the compiler behavior around const
parameter will not change, nor will const
parameters be generally removed from the core Delphi libraries.
Even if the compiler behavior around const
parameters will not change, that still
leaves room for requests to remove const
parameters and avoid their
usage in the RTL. Which brings us to the question "Are const parameters dangerous,
and should they be avoided in code?".
No, const parameters are not dangerous and they serve a purpose
const
parameters are used with the purpose of avoiding unnecessary reference
counting triggers and the performance penalty they cause. If you think that such
optimization is unnecessary, you probably haven't been using the ARC compiler in
mobile development where non-const parameters on object references (triggering
reference counting under the ARC compiler) were causing major performance issues.
That is also one (not the only one) of the reasons the ARC compiler was abandoned in
Delphi 10.4.
So const
parameters are used for a reason, and the performance penalty for not
using them would simply be too great. Anyone asking for their removal from the core
Delphi libraries, or changing compiler behavior, is actually asking every Delphi
developer to pay a price they may not be able to afford.
What does that mean for library developers? Should they stop using const
parameters? The answer is the same as for Delphi core libraries. Unless there is a
specific reason against it (read: a situation where almost every usage of particular
procedure, requires grabbing strong reference), it is better to use a const
parameter.
Situations where people using such a library will shoot themselves in the foot are extremely rare, and can be recognized and solved at the call site (like in example 3.) While there are certainly situations where spotting the problem might not be so easy, and that some dose of caution is always warranted, this is nothing extraordinary compared to the general caution required when writing any kind of code.
Note: There is known bug in the compiler when constructing object directly in function/procedure/method call. This is actual bug, and is not directly related to issues presented in this article.
Memory-Leak on Const-Parameter https://quality.embarcadero.com/browse/RSP-10100
Thanks so much for adding clarity to this issue Dalija! Too often when reading articles, we might decide on incomplete understanding to make global changes that are not beneficial and in fact could introduce new issues including performance as you state.
ReplyDelete