Monday, May 29, 2006

Call vs CallVirt for C# non-virtual instance methods

Prompted by a post over in micrsoft.public.dotnet.languages.csharp, where someone asked if the C# compiler should issue a warning for an expression like:
this == null
The answer is no, for a fairly complicated reason. If C# was the only compiler for the CLR, then the poster might have a point - calling an instance method on a null instance always throws an exception in C#. However, other languages targeting the CLR can invoke a non-virtual instance method on a null instance, without error. In particular, the Delphi object model's TObject.Free method takes advantage of this to only call the destructor for non-null objects. How does this work under the covers? Well, it comes down to the difference in semantics between the 'call' and 'callvirt' CIL instructions. Note: everything I mention in this entry applies only to non-virtual instance methods.

One time I am aware of that the C# compiler generates a 'call' instruction is when calling the base class's method for overridden virtual methods. In that case, the compiler can use 'call' since the instance can't be null because the method was (ultimately) called using virtual dynamic dispatch.

The rationale for using 'callvirt' instead of 'call' for C# non-virtual instance methods is, I would guess, to fail sooner. When calling an instance method on a null instance, the null instance is found sooner than it might be. For example, a method might check its arguments only and thereby determine that nothing needs doing in this case, and return without causing an exception. If the compiler didn't generate code that checked the instance, the fact that an instance method was called on a null reference might not be caught until later.

What's the difference in JIT-compiled code between 'call' and 'callvirt' on CLR 2.0.50727?

For this analysis, I started with this CIL:

.assembly extern mscorlib {}
.assembly Test {}
.subsystem 0x0003

.class App extends [mscorlib]System.Object
{
    .method public instance void Test()
    {
        ldstr "This is null\? {0}"
        
        ldarg.0
        ldnull
        ceq
        box [mscorlib]System.Boolean
        call void [mscorlib]System.Console::WriteLine(string,object)
        ret
    }
    
    .method public static void Main()
    {
        .entrypoint
        
        ldstr "First:"
        call void [mscorlib]System.Console::WriteLine(string)
        ldnull
        call instance void App::Test()
        
        ldstr "Second:"
        call void [mscorlib]System.Console::WriteLine(string)
        ldnull
        callvirt instance void App::Test()
        ret
    }
}
Roughly transliterated into C# code, it looks like this:
using System;

class App
{
    public void Test()
    {
        Console.WriteLine("This is null? {0}", this == null);
    }

    public static void Main()
    {
        Console.WriteLine("First:");
        ((App) null).Test(); // with 'call': not possible in MS C# 2.0
        Console.WriteLine("Second:");
        ((App) null).Test(); // with 'callvirt': default for C# compiler
    }
}
This assembly's name is Test, but I compiled it to an executable called CallVirt.exe, with ilasm, and started the VS 2005 debugger:
ilasm -debug=opt CallVirt.il
devenv -debugexe CallVirt.exe
I changed the project's debugger settings from Auto to Mixed, and stepped into the code. When disassembled with SOS, the code for the App.Main method looks like this:
.load sos
extension C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\sos.dll loaded

!name2ee CallVirt.exe App.Main
PDB symbol for mscorwks.dll not loaded
Module: 00912c14 (CallVirt.exe)
Token: 0x06000002
MethodDesc: 00912fe0
Name: App.Main()
JITTED Code Address: 00de0070

!u 00de0070
Normal JIT generated code
App.Main()
Begin 00de0070, size 61
>>> 00DE0070 833D84102B0200   cmp         dword ptr ds:[022B1084h],0
00DE0077 750A             jne         00DE0083
00DE0079 B901000000       mov         ecx,1
00DE007E E889D75678       call        7934D80C 
         (System.Console.InitializeStdOutError(Boolean), mdToken: 0600070f)
00DE0083 8B0D84102B02     mov         ecx,dword ptr ds:[022B1084h]
00DE0089 8B153C302B02     mov         edx,dword ptr ds:[022B303Ch]
00DE008F 8B01             mov         eax,dword ptr [ecx]
00DE0091 FF90D8000000     call        dword ptr [eax+000000D8h]
00DE0097 33C9             xor         ecx,ecx
00DE0099 FF1520309100     call        dword ptr ds:[00913020h]
00DE009F 833D84102B0200   cmp         dword ptr ds:[022B1084h],0
00DE00A6 750A             jne         00DE00B2
00DE00A8 B901000000       mov         ecx,1
00DE00AD E85AD75678       call        7934D80C 
         (System.Console.InitializeStdOutError(Boolean), mdToken: 0600070f)
00DE00B2 8B0D84102B02     mov         ecx,dword ptr ds:[022B1084h]
00DE00B8 8B1540302B02     mov         edx,dword ptr ds:[022B3040h]
00DE00BE 8B01             mov         eax,dword ptr [ecx]
00DE00C0 FF90D8000000     call        dword ptr [eax+000000D8h]
00DE00C6 33C9             xor         ecx,ecx
00DE00C8 3909             cmp         dword ptr [ecx],ecx
00DE00CA FF1520309100     call        dword ptr ds:[00913020h]
00DE00D0 C3               ret
This code is longer that strictly "necessary" because the Console::WriteLine(string) method has been inlined. The two relevant snippets of code for 'call' and 'callvirt', including the setting of the 'this' argument to null, are as follows:
// CALL
00DE0097 33C9             xor         ecx,ecx
00DE0099 FF1520309100     call        dword ptr ds:[00913020h]

// CALLVIRT
00DE00C6 33C9             xor         ecx,ecx
00DE00C8 3909             cmp         dword ptr [ecx],ecx
00DE00CA FF1520309100     call        dword ptr ds:[00913020h]
Thus, the difference with CALLVIRT is that it tests the pointer by dereferencing it. That causes a hardware exception when the pointer is null, and that hardware exception gets propagated to the CLR via Windows SEH.

Something interesting that can be observed from this: the calls to the App.Test() method are through an indirection. A peek in the address shows the data:

>d -format:fourbytes 0x00913020
0x00913020  00de00e8 00de0070 00000080 022b1ec4  
0x00913030  912fd8b8 e9ed8900 ffa2eed0 912fe0b8  
0x00913040  e9ed8900 ffa2eec4 576f62e8 cccc5e79  
0x00913050  00912fe0 00000000 00000000 00000000  
One can then disassemble the code at the indirect location:
!u 0x00de00e8

Normal JIT generated code
App.Test()
Begin 00de00e8, size 44
>>> 00DE00E8 57               push        edi

... etc.
So, calls to non-virtual instance methods compiled with the current C# compiler get turned into CIL 'callvirt' instructions, which, with the current JIT compiler, test the 'this' argument with a CMP instruction. Other languages which use 'call' simply call straight through without the test.

1 comment:

Robert said...

I came across the very same thing in the Chrome language.
Non-virtual or final virtual methods won't let the compiler emit callvirt, as long as he knows, that the method really is statically linkable.
Interesting, indeed. :-) Always thought you simply can't call a method on a null reference and nether really tried the nasty. *g*
But it does in fact work. :-)
Sample code:

Class1 = public class
public
method ToString : String; override; final;
property SomeProperty : String;
end;

implementation

method Class1.ToString : String;
begin
if assigned(Self) then
result := SomeProperty
else
result := '(nüschts)';
end;

When you run this code:

var noInstance : Class1;
var isInstance := new Class1(SomeProperty := 'blabla');

Console.WriteLine(noInstance.ToString());
Console.WriteLine(isInstance.ToString());

it will print:
(nüschts)
blabla