Sunday, April 11, 2021

Out parameters are just bad var parameters

After talking about const parameters last time today we take a closer look at out parameters and how they differ from var parameters.

I like the concept of out parameters as they clearly state that only the value that comes back matters and not the value that goes in. However, the Delphi implementation of out parameters has some aspects to it that can cause a decision against them and rather choose a var parameter. As last time we will look at some assembler code that shows what those differences are. Out parameters like var parameters pass a reference to the value rather than the value itself.

The code for this article will be this:
{$APPTYPE CONSOLE}
{$O+,W-}
type
  TTestType = ...;

procedure C(z: TTestType);
begin
end;

procedure A(var y: TTestType);
begin
  y := Default(TTestType);
end;

procedure B(out y: TTestType);
begin
  y := Default(TTestType);
end;

procedure Main;
var
  x: TTestType;
begin
  A(x);
  B(x);
end;
Like last time, we will inspect the code for different types for TTestType and see how it differs. Let's start with Integer - the code we will see for the calls and inside A and B are as follows:
OutParams.dpr.24: A(x);
00408FA5 8BC4             mov eax,esp
00408FA7 E8E8FFFFFF       call A
OutParams.dpr.25: B(x);
00408FAC 8BC4             mov eax,esp
00408FAE E8E9FFFFFF       call B
OutParams.dpr.12: y := Default(TTestType);
00408F94 33D2             xor edx,edx
00408F96 8910             mov [eax],edx
OutParams.dpr.13: end;
00408F98 C3               ret
OutParams.dpr.17: y := Default(TTestType);
00408F9C 33D2             xor edx,edx
00408F9E 8910             mov [eax],edx
OutParams.dpr.18: end;
00408FA0 C3               ret
Nothing really fancy here - esp is the address of the local variable x. The (unfortunately still undocumented) intrinsic function Default() takes care of setting the parameter to the default value of the given type - so in this case 0. The code for the var parameter will look exactly the same. For other types, the code will look similar but something interesting happens when we use a managed type such as string. Take a look at how this changes the code being generated:
OutParams.dpr.24: A(x);
00408FCB 8D45FC           lea eax,[ebp-$04]
00408FCE E8C1FFFFFF       call A
OutParams.dpr.25: B(x);
00408FD3 8D45FC           lea eax,[ebp-$04]
00408FD6 E8F1CEFFFF       call @UStrClr
00408FDB E8C0FFFFFF       call B
On the caller side, we now see a call to System._UStrClr which the compiler inserted here to ensure that x will be empty before being passed to A. Out parameters are always initialized before they are passed, unlike var parameters. When we will take a look at the code inside A we will see nothing unusual except a lack of optimization. eax could directly be passed to System._UStrClr - and that is not a result of using Default() but also happens when assigning '' to y:
OutParams.dpr.11: begin
00408F94 53               push ebx
00408F95 8BD8             mov ebx,eax
OutParams.dpr.12: y := Default(TTestType);
00408F97 8BC3             mov eax,ebx
00408F99 E82ECFFFFF       call @UStrClr
OutParams.dpr.13: end;
00408F9E 5B               pop ebx
00408F9F C3               ret
The interesting difference however will be obvious when we will look into B:
OutParams.dpr.16: begin
00408FA0 55               push ebp
00408FA1 8BEC             mov ebp,esp
00408FA3 53               push ebx
00408FA4 8BD8             mov ebx,eax
00408FA6 85DB             test ebx,ebx
00408FA8 7404             jz $00408fae
00408FAA 33C0             xor eax,eax
00408FAC 8903             mov [ebx],eax
OutParams.dpr.17: y := Default(TTestType);
00408FAE 8BC3             mov eax,ebx
00408FB0 E817CFFFFF       call @UStrClr
OutParams.dpr.18: end;
00408FB5 5B               pop ebx
00408FB6 5D               pop ebp
00408FB7 C3               ret
What is going on here? The first two instructions are setting up a frame pointer which is usual but would not be necessary in this case - especially since we turned off that option. The following lines basically ensure that our y parameter really is empty - again with a lack of optimization.

Why is this happening? The caller side ensured that y is empty. This code is for C++Builder compatibility! C++ does not have the concept of out parameter but just by reference parameter which equals the var parameter in Delphi. And because of that when C++ would call such a routine the value would not have been initialized. Unfortunately, at least to my knowledge, there is no option to turn this off because our code will never be called from C++. We have built an executable here in Delphi; our function is not exported nor did we make a DLL. When using out parameters you pay a price for some most likely unused feature as most if not all Delphi code you ever write will never be called from any C++ code. The impact of out parameters is so significant in some cases that in 10.4 some of them were changed to var in the RTL - take a look at most parameters of TValue in the unit System.Rtti. Because for records this overhead can be even bigger and even more when they are passed multiple levels of out parameters because every call and routine again produces this completely unnecessary overhead.

The entire concept of out parameters to me looks completely unfinished - let's for a second take a look at some C# code which also has out parameters
static void B(out int y)
{
    WriteLine(y);
}
This code will raise two errors:
Error	CS0269	Use of unassigned out parameter 'y'
Error	CS0177	The out parameter 'y' must be assigned to before control leaves the current method
And the compiler is right - since the parameter is out the value going in actually is undefined behavior - and if the compiler ensures that it cannot be used then it also does not need to do any initialization ensuring any value. That means no unnecessary work to do and no inconsistent behavior - remember unmanaged data types don't get that extra treatment, whatever value is in an int variable being passed is still in there when being passed to an out parameter.
Second, the compiler ensures setting a value to the out parameter ensuring it is not undefined when returning from this routine. Would an exception occur during the invocation of that routine and before the out parameter was set then upon returning and catching the exception the variable would still contain the value it had before - a totally sane and understandable behavior.

As for me - I will be more careful where I use out parameters and in fact, during the refactoring of the collections in Spring4D, I replaced most out parameters with var parameters. Since even without the compiler enforcing this, all code paths inside those methods eventually set the value of the parameter it will be no change in behavior for you but avoid this completely insane overhead.

6 comments:

  1. A very interesting analysis. Thanks :-)

    Isn't it the case that the compiler, knowing that a parameter is *out*, can better optimise the code before or after calling a subroutine with such parameter?

    ReplyDelete
  2. Though the analysis is excellent, I disagree with the point made up here. It is classical case of programming in language vs programming into language as Steve McConnell is calling it. It means, it is nice to know such nuances in case of optimization. But they are very rare cases, that such a low-level optimization is needed. You should rather not limit yourself by fitting to the compiler behavior in cost of clarity of code. In this case of double-thinking whether your "var" means really "var" or it means "out".

    ReplyDelete
  3. @Stefan I don't agree with your point either.
    To be fair, "out" parameters work as designed - the fact that managed values are initialized before the call is not something bad or good. It is a feature, documented as such, which makes sense in some/most cases.
    If you don't want the features the "out" parameters bring with themselves, just use a "var" and forget about the former. The fact that "out" parameters don't work as you expect them to, even in regard to C#, is not relevant.
    @Grzegorz About optimization of the "out" value, there is no such thing in Delphi nor FPC AFAICT. The compiler could easily optimize the caller initialization code in a pure-Delphi context, if C++ is not involved (i.e putting the clear in the called function, not in the caller stub). Even making a dual initialization in case of C++ call would not hurt much the performance.

    ReplyDelete
    Replies
    1. My point is not only the initialization before the call but also the unnecessary code that happens inside of the routine plus the fact that nested calls initialize over and over and not just pass through. Hence the change in System.Rtti because there it caused significant impact.
      Comparing with C# was to show that the initialization before the call is completely unnecessary if the parameter was also treated like a true out value by the compiler inside of the routine and would not require any prior initialization.
      Same is the case for managed return values that are just hidden var parameters and are not initialized but allow to be accessed inside the routine which often leads to defects if you for example write Result := Result + something which the compiler could easily prevent by not allowing reading Result prior to being set.

      Delete
  4. The code putting "Default" inside an "out" variable doesn't make sense. If you want to use intrinsics, just test if the type is managed or not, then assign "Default" only if it is not managed. The code would be clean as you expect it to be.

    ReplyDelete