When this == null: eine fiktive Geschichte aus der CLR-Welt

    Ich hatte einmal die Gelegenheit, einen solchen C # -Code zu debuggen, der "aus heiterem Himmel" entstand aus NullReferenceException:

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

    Ja, genau in diesem Zusammenhang mit der Vergabe eines Grundstücks bin ich gestürzt NullReferenceException. Welche Art von Geschäft, denke ich, hat die Laufzeit die Überprüfung auf eine Instanz vor dem Aufruf der Instanzmethoden gestoppt?

    Wie sich herausstellte , habe ich in gewisser Weise aufgehört . Es stimmt, der Compiler entpuppt sich als nicht derjenige, für den er sich ausgibt, und die Überprüfungen werden zur Laufzeit überhaupt nicht garantiert.

    Für diejenigen, die mit den Besonderheiten von C # nicht vertraut sind, erkläre ich die Kette meiner Gedanken. In der Klasse Testergibt es also eine Instanzmethode Foound eine Instanzeigenschaft Property. Jemand rufen Sie die Methode Foo, aber in Bezug auf die this.Propertyoffenbarte eine Überraschung , dass die Erzeugung der Laufzeitausnahme geführt NullReferenceException.

    In einer normalen Situation kann diese Ausnahme bedeuten, dass sich eine bestimmte Zeichenfolge in der Zeichenfolge this == nullbefindet und die Zeichenfolge this.Property = smthdaher nicht auf die Eigenschaft zugreifen kann. Für einen C # -Programmierer klingt dies jedoch völlig unmöglich - denn wenn eine Methode irgendwie aufgerufen wurde Foo, ist eine Instanz der Klasse vorhanden und thiskann nicht gleich sein null! Wie könnte man die Methode y nennen null?

    Und trotzdem zeigt er hier auf diese Linie! Wir fangen an, an allem zu zweifeln, einschließlich unserer eigenen Vernunft, und schreiben das folgende Testprogramm in C #:

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

    Kompilieren, ausführen - ja, das Programm stürzt NullReferenceExceptionin der Zeile ab t.Foo();, Foogibt aber die Methode nicht ein. Dies ist, was passiert, unter bestimmten Umständen Laufzeit vergessen, eine Überprüfung durchzuführen null?

    In der Tat nein. (Rantime führt diese Überprüfung überhaupt nicht durch .) Der Compiler ist natürlich nicht für alles verantwortlich, was passiert. Nur ist hier nicht der C # -Compiler (der offensichtlich den Gesetzen entspricht und den Aufruf der Methode y nicht zulässt null), sondern der C ++ / CLI-Compiler, mit dessen Hilfe der Code kompiliert wurde, der die Methode auf die ursprüngliche Weise aufgerufen hat Foo. Ja, die Teilnahme von C ++ / CLI an dieser Geschichte würde sofort eine Menge Verdacht erregen, und ich habe dies zunächst besonders geschwiegen, um es interessanter zu machen :)

    Lassen Sie uns nun unsere Experimente fortsetzen und dasselbe Programm in C ++ / CLI schreiben (dazu müssen Sie der Assembly, die die Klasse enthält, einen Link hinzufügen Tester):

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

    Kompilieren, ausführen - bam! Drops NullReferenceExceptioninnerhalb der Methode Foo, genau wie im Originalfall. Das heißt, die Instanzmethode wurde Fooirgendwie an der Nullreferenz aufgerufen, wobei alle Überprüfungen umgangen wurden.

    Was ist los? Wir haben zwei völlig identische Programme in verschiedenen Sprachen in unseren Händen. Wir gehen davon aus, dass sie in nahezu denselben (oder zumindest ähnlichen) Bytecode kompiliert werden sollten, wenn die Compiler beider Sprachen die CLI-Spezifikationen erfüllen. Wir beginnen mit dem empfangenen Bytecode umzugehen. Wir nehmen ildasmund analysieren den Programmcode in C #. Ich gebe eine vollständige Auflistung der Methode Program.Main(in den Kommentaren habe ich die dem Bytecode entsprechenden Quellcodezeilen angegeben):

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

    Das interessanteste hier ist die Linie IL_0004. Wir sehen, dass der Compiler die Methode Foomit der Anweisung aufgerufen hat callvirt. Vergleichen Sie nun mit dem entsprechenden C ++ / CLI-Code:

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

    Von den für uns interessanten Änderungen ist neben der doppelten Nullsetzung der Variablen der Methodenaufruf hier nicht durch callvirt, sondern durch call.

    Der CIL-Befehl callvirtist eigentlich für virtuelle Anrufe. Es gibt jedoch noch eine weitere kleine Funktion: Da virtuelle Aufrufe normalerweise in der CLI über die virtuelle Methodentabelle ausgeführt werden, callvirtist es auch Aufgabe des Befehls , die Verknüpfung zu zu überprüfen nullund eine Ausnahme NullReferenceExceptionauszulösen, wenn ein Fehler auftritt.

    Die Anweisung callruft die Methode einfach auf, ohne die Verknüpfungen zu überprüfen (und ohne virtuelle Verteilungsmechanismen zu verwenden).

    Es stellt sich heraus, dass der C # -Compiler nur eine Funktion der Anweisung verwendetcallvirtund generiert es daher für alle Aufrufe im Allgemeinen (außer für statische und explizite Aufrufe von Methoden der Basisklasse durch base.) - nur weil es den Code davor schützt, die Methode an der Nullreferenz aufzurufen. Gleichzeitig handelt der C ++ / CLI-Compiler nach den guten alten Gesetzen des Wilden Westens undefiniertes Verhalten: Sind die Inhalte des Links nicht definiert, so ist auch das Verhalten des Programms nicht definiert. Wenn der Compiler weiß, dass die Methode nicht virtuell sein kann, wird er nicht versuchen, virtuelle Aufrufe zu generieren.

    Ob sich dieses Verhalten des C # -Compilers auf die Leistung auswirkt und inwieweit, ist eine offene Frage. Grundsätzlich sollte sich JIT in den meisten Fällen mit der Optimierung und Inlining eines solchen Codes befassen, wenn die aufgerufenen Methoden tatsächlich nicht virtuell sind. In dieser Hinsicht verlässt sich der C # -Compiler vollständig auf JIT und unternimmt seinerseits keine Optimierungsversuche.

    Im Zusammenhang mit den untersuchten Fakten ist es auch interessant, hier ein Fragment des veröffentlichten Klassencodes System.String, der einst Fragen zu StackOverflow aufwirft :

            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);
            }
    

    Jetzt wird klar, was in dem Kommentar steht (diese Kommentare waren jedoch nicht immer vorhanden) und unter welchen Bedingungen diese Prüfung möglicherweise funktioniert.

    In mehreren Methoden mussten sich die Entwickler des Frameworks auf nulldiese Weise gegen Methodenaufrufe wehren . Tatsache ist, dass der String-Vergleich in der Methode EqualsHelpermithilfe eines unsafe-codes implementiert wird , der möglicherweise versucht, auf den Speicher unter der Adresse Null zuzugreifen, was mit Sicherheit zu allen möglichen negativen Konsequenzen führt.
    UPD: Der Benutzer a553 vermerkt in den Kommentaren korrekt, dass die Entwickler mit diesem Code unter anderem einen potenziellen Fehler behoben haben, bei dem der Aufruf ((string)null).Equals(null)zurückkehren falseund nicht abfallen konnte, NullReferenceExceptionwie er sein sollte.

    Schlussfolgerungen:


    1. Die CLI garantiert dies nicht, this != nullselbst wenn Instanzmethoden und -eigenschaften aufgerufen werden.
    2. Der C # -Compiler beachtet diese Regel beim Generieren von Bytecode für C # -Code. Ihr Code kann jedoch auch aus anderen Sprachen aufgerufen werden.
    3. Insbesondere der C ++ / CLI-Compiler entspricht nicht diesen Regeln und kann die Steuerung möglicherweise an Instanzmethoden übergeben, ohne die entsprechende Instanz zu definieren.
    4. Daraus folgt, dass Ihr Code manchmal this == nullaus verschiedenen Gründen (Codegenerierung, Reflektion, Compiler anderer Sprachen) im Kontext aufgerufen werden kann und Sie darauf vorbereitet sein müssen. Wenn Sie eine Bibliothek entwickeln, die für die allgemeine Verwendung in einer Interop-Umgebung vorgesehen ist, ist es möglicherweise sogar sinnvoll, die nullöffentlichen Methoden von extern zugänglichen Klassen zu überprüfen .

    PS:


    Der gesamte im Artikel verwendete Code ist auf github verfügbar .

    Jetzt auch beliebt: