⬆️ ⬇️

When this == null: the untamed story from the world of the CLR

Once it was possible to debug such a code in C #, which “out of the blue” fell from a NullReferenceException :



  public class Tester { public string Property { get; set; } public void Foo() { this.Property = "Some string"; // NullReferenceException } } 


Yes, here on this very line with the assignment of the property fell NullReferenceException . What business, I think - really rantaym stopped checking the presence of an instance before calling instance methods?



As it turned out - in a way, yes, it stopped . True, the compiler turned out to be not who it claims to be, and the checks are not guaranteed by runtime at all ... In more detail - under the cut.



For those who are not familiar with the specifics of C #, I will explain the chain of their reflections. So, in the Tester class there is an instance method Foo and an instance property Property . Someone called the Foo method, but a this.Property found on the call to this.Property , which led to the generation of the NullReferenceException exception.

')

Normally, this exception would mean that this == null in the string, and therefore the string this.Property = smth cannot access the property. But for a C # programmer, this sounds completely impossible - after all, if the Foo method was somehow called, the class instance exists and this cannot be null ! How could you call a method on null ?



And nevertheless, the spectra, here it is, points to this line! We begin to doubt everything, including our own responsibility, and write the following test program in C #:



 static class Program { static void Main() { Tester t = null; t.Foo(); } } 


Compile, execute - yes, the program crashes with a NullReferenceException on the string t.Foo(); , but does not enter the Foo method. It that turns out, under any conditions rantaym forgot to execute check on null ?



Not really. (Rantaim doesn’t perform this check at all .) The compiler is of course guilty of everything that happens, of course, not runtime. The only thing is not the C # compiler (which, obviously, on its side complies with the laws and does not allow calling the method in null ), but the C ++ / CLI compiler, with which the code was compiled, which in the original way called the Foo method. Yes, the participation of C ++ / CLI in this story would immediately have caused a lot of suspicion, and I didn’t say anything special about it at first, so that it was more interesting :)



Well, let's continue the experiments and write the same program in C ++ / CLI (to do this, add a link to the assembly containing the Tester class):



 int main() { Tester ^t = nullptr; t->Foo(); } 


Compile, run - bang! A NullReferenceException inside the Foo method, just like in the original case. That is, the instance method Foo somehow called from the zero reference, bypassing any checks.



What is going on? We have in our hands two completely identical programs in different languages. We assume that they should be compiled into almost the same (or at least similar) bytecode if the compilers of both languages ​​conform to the CLI specifications. We start to deal with the received baytkod. Take ildasm and parse the program code in C #. I give a full listing of the Program.Main method (in the comments I gave the source code lines corresponding to the bytecode):



 .method private hidebysig static void Main() cil managed { .entrypoint // Code size 11 (0xb) .maxstack 1 .locals init ([0] class [Shared]ThisIsNull.Tester t) IL_0000: nop IL_0001: ldnull IL_0002: stloc.0 // Tester t = null; IL_0003: ldloc.0 IL_0004: callvirt instance void [Shared]ThisIsNull.Tester::Foo() // t.Foo() IL_0009: nop IL_000a: ret } 


The most interesting thing here is the string IL_0004 . We see that the compiler has called the Foo method using the callvirt . Now compare with the corresponding code in C ++ / CLI:



 .method assembly static int32 modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl) main() cil managed { .vtentry 1 : 1 // Code size 12 (0xc) .maxstack 1 .locals ([0] class [Shared]ThisIsNull.Tester t) IL_0000: ldnull IL_0001: stloc.0 // Tester ^t = nullptr; IL_0002: ldnull IL_0003: stloc.0 // t = nullptr; IL_0004: ldloc.0 IL_0005: call instance void [Shared]ThisIsNull.Tester::Foo() // t->Foo(); IL_000a: ldc.i4.0 IL_000b: ret } 


Of the changes that are interesting to us, besides double zeroing a variable, the method call here is not via callvirt , but via call .



The CIL callvirt is actually intended for virtual calls. However, it has another small feature - since virtual calls are usually made to the CLI via a virtual method table, it is the responsibility of the callvirt also check the null reference and throw a NullReferenceException exception if something went wrong.



The call instruction simply calls the method without checking the links (and not using the virtual dispatch mechanisms).



It turns out that the C # compiler simply uses the callvirt instruction feature and therefore generates it for all calls in general (except for static and explicit calls to the base class methods through the base. ) - just because it protects the code from the method call of the zero reference. At the same time, the C ++ / CLI compiler operates according to the good old laws of the wild West undefined behavior: if the content of the link is not defined, then the behavior of the program is also not defined. If the compiler knows that the method cannot be virtual, then it will not try to generate virtual calls.



Does this behavior of the C # compiler affect performance, and if so, to what extent - an open question. In theory, in most cases, JIT should handle the optimization and inlining of such code, if in fact the methods being called are not virtual. The C # compiler relies entirely on JIT in this regard and, for its part, does not make any optimization attempts.



In the context of the investigated facts, for example, such a fragment of the published code of the class System.String , which once caused questions on StackOverflow , is also interesting:



  public bool Equals(String value) { if (this == null) //this is necessary to guard against reverse-pinvokes and throw new NullReferenceException(); //other callers who do not use the callvirt instruction if (value == null) return false; if (Object.ReferenceEquals(this, value)) return true; return EqualsHelper(this, value); } 


Now it becomes clear what the commentary says (however, these comments were not always there), and under what conditions this check might work.



In several methods, the framework developers had to defend against calls to null in this way. The fact is that the comparison of strings in the EqualsHelper method EqualsHelper implemented using an unsafe code, which may well try to access the memory section at the zero address, which will certainly lead to all sorts of bad consequences.

UPD: User a553 rightly notes in the comments that this code developers, among other things, fixed a potential error in which the ((string)null).Equals(null) call could return false , rather than fall from a NullReferenceException , as it should.



Findings:



  1. CLI does not guarantee that this != null even when invoking instance methods and properties.
  2. The C # compiler complies with this rule when generating the bytecode for C # code, but your code can be called from other languages.
  3. In particular, the C ++ / CLI compiler does not follow these rules and may well transfer control to instance methods without defining the corresponding instance.
  4. It follows that your code can sometimes be called in the context of this == null for various reasons (code generation, reflection, compilers of other languages), and you need to be ready for this. If you are developing a library intended for widespread use in an interop environment, you may even need to add null checks to public methods of externally accessible classes.


PS:



All code used in the article is available on github .

Source: https://habr.com/ru/post/252249/



All Articles