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

Comments

  1. 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

Post a Comment

Popular posts from this blog

Coming in Delphi 12: Disabled Floating-Point Exceptions

Beware of loops and tasks

Catch Me If You Can - Part II