Erinnern Sie sich gut an nullbare Werttypen? Wir schauen "unter die Haube"

image1.png


In letzter Zeit sind nullfähige Referenztypen zu einem heißen Thema geworden. Die guten alten nullbaren Werttypen sind jedoch nicht verschwunden und werden immer noch aktiv verwendet. Erinnerst du dich gut an die Nuancen der Arbeit mit ihnen? Ich schlage vor, dass Sie Ihr Wissen aktualisieren oder testen, indem Sie diesen Artikel lesen. Beispiel-C # - und IL-Code, Verweise auf die CLI-Spezifikation und den CoreCLR-Code sind enthalten. Ich schlage vor, mit einem interessanten Problem zu beginnen.



Hinweis . Wenn Sie an nullbaren Referenztypen interessiert sind, können Sie einige Artikel meiner Kollegen lesen : " Nullbare Referenztypen in C # 8.0 und statische Analyse ", " Nullbare Referenzen schützen nicht und hier ist der Beweis ".



Schauen Sie sich den folgenden Beispielcode an und beantworten Sie, was an die Konsole ausgegeben wird. Und genauso wichtig, warum. Lassen Sie uns einfach sofort zustimmen, dass Sie so antworten, wie es ist: ohne Compiler-Hinweise, Dokumentation, Lesen von Literatur oder ähnliches. :) :)



static void NullableTest()
{
  int? a = null;
  object aObj = a;

  int? b = new int?();
  object bObj = b;

  Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // True or False?
}


image2.png


Nun, lass uns ein wenig nachdenken. Nehmen wir ein paar Hauptgedanken, die sich meines Erachtens ergeben können.



1. Ausgehend von der Tatsache, dass int? - Referenztyp.



Lassen Sie uns so argumentieren, was ist int? Ist ein Referenztyp. In diesem Fall wird ein Wert auf null geschrieben , er wird ebenfalls aufgezeichnet und nach der Zuweisung aObj . Ein Verweis auf ein Objekt wird in b geschrieben . Es wird auch nach der Zuweisung an bObj geschrieben . Infolgedessen verwendet Object.ReferenceEquals null und eine Objektreferenz ungleich Null als Argumente , also ...



Es ist offensichtlich, dass die Antwort False ist!



2. Wir gehen davon aus, dass int? - signifikanter Typ.



Oder bezweifeln Sie das int? Ist ein Referenztyp? Und sind Sie sich dessen trotz des int- Ausdrucks sicher ? a = null ? Nun, lass uns von der anderen Seite gehen und von dem ausgehen, was int ist. - signifikanter Typ.



In diesem Fall ist der Ausdruck int? a = null sieht ein wenig seltsam aus, aber nehmen wir an, dass wieder in C # Zucker darüber gegossen wurde. Es stellt sich heraus, dass ein Objekt speichert. b speichert auch eine Art Objekt. Bei der Initialisierung der Variablen aObj und bObj werden die in a und b gespeicherten Objekte gepackt, wodurch unterschiedliche Verweise auf aObj und bObj geschrieben werden . Es stellt sich heraus, dass Object.ReferenceEquals Verweise auf verschiedene Objekte als Argumente verwendet, daher ...



Alles ist offensichtlich, die Antwort ist False!



3. Wir gehen davon aus, dass hier Nullable <T> verwendet wird .



Angenommen, Ihnen haben die oben genannten Optionen nicht gefallen. Weil du genau weißt, dass es kein int gibt? Eigentlich nicht, aber es gibt einen Werttyp Nullable <T> , und in diesem Fall wird Nullable <int> verwendet . Auch verstehen Sie, dass in der Tat in a und bEs wird identische Objekte geben. Gleichzeitig haben Sie nicht vergessen, dass beim Schreiben von Werten in aObj und bObj ein Packen erfolgt und dadurch Verweise auf verschiedene Objekte erhalten werden. Da Object.ReferenceEquals Verweise auf verschiedene Objekte akzeptiert, ist ...



Es ist offensichtlich, dass die Antwort falsch ist!



4.;)



Für diejenigen, die mit Werttypen begonnen haben - Wenn Sie plötzlich Zweifel am Vergleichen von Referenzen haben, können Sie die Dokumentation zu Object.ReferenceEquals unter docs.microsoft.com lesen... Insbesondere wird auch das Thema Werttypen und Packen / Auspacken angesprochen. Es stimmt, es beschreibt einen Fall, in dem Instanzen signifikanter Typen direkt an die Methode übergeben werden. Wir haben die Verpackung separat herausgenommen, aber das Wesentliche ist dasselbe.



Beim Vergleich von Werttypen. Wenn objA und objB Werttypen sind, werden sie eingerahmt, bevor sie an die ReferenceEquals-Methode übergeben werden. Dies bedeutet, dass die ReferenceEquals- Methode dennoch false zurückgibt , wenn sowohl objA als auch objB dieselbe Instanz eines Wertetyps darstellen , wie das folgende Beispiel zeigt.



Es scheint, dass hier der Artikel fertiggestellt werden kann, aber nur ... die richtige Antwort ist wahr .



Nun, lass es uns herausfinden.



Verstehen



Es gibt zwei Möglichkeiten - einfach und interessant.



Der einfache Weg



int? Ist nullable <int> . Öffnen Sie die Nullable <T> -Dokumentation , in der wir uns den Abschnitt "Boxing and Unboxing" ansehen. Im Prinzip ist das alles - das Verhalten wird dort beschrieben. Aber wenn Sie mehr Details wünschen, lade ich Sie auf einen interessanten Weg ein. ;)



Interessanter Weg



Wir werden nicht genug Dokumentation auf diesem Weg haben. Sie beschreibt das Verhalten, beantwortet aber nicht die Frage 'warum'?



Was ist eigentlich ein Int? und null im entsprechenden Kontext? Warum funktioniert das so? Verwendet der IL-Code unterschiedliche Befehle oder nicht? Ist das Verhalten auf CLR-Ebene unterschiedlich? Irgendeine andere Magie?



Beginnen wir mit dem Parsen der int- Entität ? sich an die Grundlagen zu erinnern und schrittweise zur Analyse des ursprünglichen Falles zu gelangen. Da C # eine ziemlich "üppige" Sprache ist, werden wir uns regelmäßig auf den IL-Code beziehen, um die Essenz der Dinge zu untersuchen (ja, C # -Dokumentation ist heute nicht unser Weg).



int ?, Nullable <T>



Hier werden wir im Prinzip die Grundlagen von nullbaren Werttypen betrachten (was sie sind, was sie in IL kompilieren usw.). Die Antwort auf die Frage aus der Aufgabe wird im nächsten Abschnitt besprochen.



Schauen wir uns einen Code an.



int? aVal = null;
int? bVal = new int?();
Nullable<int> cVal = null;
Nullable<int> dVal = new Nullable<int>();


Obwohl die Initialisierung dieser Variablen in C # unterschiedlich aussieht, wird für alle derselbe IL-Code generiert.



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              valuetype [System.Runtime]System.Nullable`1<int32> V_1,
              valuetype [System.Runtime]System.Nullable`1<int32> V_2,
              valuetype [System.Runtime]System.Nullable`1<int32> V_3)

// aVal
ldloca.s V_0
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// bVal
ldloca.s V_1
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// cVal
ldloca.s V_2
initobj  valuetype [System.Runtime]System.Nullable`1<int32>

// dVal
ldloca.s V_3
initobj  valuetype [System.Runtime]System.Nullable`1<int32>


Wie Sie sehen können, wird in C # alles mit syntaktischem Zucker aus dem Herzen gewürzt, damit Sie und ich tatsächlich besser leben können:



  • int? - signifikanter Typ.
  • int? - das gleiche wie Nullable <int>. Der IL-Code arbeitet mit Nullable <int32> .
  • int? aVal = null ist dasselbe wie Nullable <int> aVal = new Nullable <int> () . In IL wird dies zu einer initobj- Anweisung erweitert , die die Standardinitialisierung an der geladenen Adresse durchführt.


Betrachten Sie den folgenden Code:



int? aVal = 62;


Wir haben die Standardinitialisierung herausgefunden - wir haben den entsprechenden IL-Code oben gesehen. Was passiert hier, wenn wir aVal auf 62 initialisieren wollen ?



Werfen wir einen Blick auf den IL-Code:



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0)
ldloca.s   V_1
ldc.i4.s   62
call       instance void valuetype 
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)


Auch hier nichts Kompliziertes - die Adresse aVal wird auf den Auswertungsstapel geladen , ebenso der Wert 62, wonach der Konstruktor mit der Signatur Nullable <T> (T) aufgerufen wird . Das heißt, die folgenden zwei Ausdrücke sind vollständig identisch:



int? aVal = 62;
Nullable<int> bVal = new Nullable<int>(62);


Sie können dasselbe sehen, indem Sie sich den IL-Code noch einmal ansehen:



// int? aVal;
// Nullable<int> bVal;
.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              valuetype [System.Runtime]System.Nullable`1<int32> V_1)

// aVal = 62
ldloca.s   V_0
ldc.i4.s   62
call       instance void valuetype                           
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

// bVal = new Nullable<int>(62)
ldloca.s   V_1
ldc.i4.s   62
call       instance void valuetype                             
           [System.Runtime]System.Nullable`1<int32>::.ctor(!0)


Was ist mit Inspektionen? Wie sieht beispielsweise der folgende Code tatsächlich aus?



bool IsDefault(int? value) => value == null;


Zum Verständnis wenden wir uns zum Verständnis noch einmal dem entsprechenden IL-Code zu.



.method private hidebysig instance bool
IsDefault(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
  .maxstack  8
  ldarga.s   'value'
  call       instance bool valuetype 
             [System.Runtime]System.Nullable`1<int32>::get_HasValue()
  ldc.i4.0
  ceq
  ret
}


Wie Sie vielleicht vermutet haben, gibt es wirklich keine Null - alles, was passiert, ist ein Aufruf der Eigenschaft Nullable <T> .HasValue . Das heißt, dieselbe Logik in C # kann in Bezug auf die wie folgt verwendeten Entitäten expliziter geschrieben werden.



bool IsDefaultVerbose(Nullable<int> value) => !value.HasValue;


IL-Code:



.method private hidebysig instance bool 
IsDefaultVerbose(valuetype [System.Runtime]System.Nullable`1<int32> 'value')
cil managed
{
  .maxstack  8
  ldarga.s   'value'
  call       instance bool valuetype 
             [System.Runtime]System.Nullable`1<int32>::get_HasValue()
  ldc.i4.0
  ceq
  ret
}




Fassen wir zusammen:



  • Nullable-Werttypen werden auf Kosten des Nullable <T> -Typs implementiert .
  • int? - tatsächlich der konstruierte Typ des generischen Werttyps Nullable <T> ;
  • int? a = null - Initialisierung eines Objekts vom Typ Nullable <int> mit dem Standardwert, hier gibt es tatsächlich keine Null ;
  • if (a == null) - wieder gibt es keine null , es gibt einen Aufruf der Eigenschaft Nullable <T> .HasValue .


Der Quellcode vom Typ Nullable <T> kann beispielsweise auf GitHub im Dotnet / Runtime-Repository angezeigt werden - ein direkter Link zur Quellcodedatei . Es gibt dort nicht viel Code, daher rate ich Ihnen aus Gründen des Interesses, durchzusehen. Von dort aus können Sie die folgenden Fakten lernen (oder sich daran erinnern).



Der Einfachheit halber definiert der Typ Nullable <T> :



  • impliziter Konvertierungsoperator von T nach Nullable <T> ;
  • expliziter Umwandlungsoperator von Nullable <T> zu T .


Die Hauptlogik der Arbeit wird durch zwei Felder (und entsprechende Eigenschaften) implementiert:



  • T-Wert - der Wert selbst, der mit Nullable <T> umbrochen wird ;
  • bool hasValue ist ein Flag, das angibt, ob der Wrapper einen Wert enthält. In Anführungszeichen, wie in der Tat Nullable <T> enthält immer einen Wert vom Typ T .


Nachdem wir nun eine Auffrischung der nullbaren Werttypen haben, wollen wir sehen, was mit der Verpackung los ist.



Nullable <T> Verpackung



Ich möchte Sie daran erinnern, dass beim Packen eines Objekts eines Werttyps ein neues Objekt auf dem Heap erstellt wird. Das folgende Codefragment veranschaulicht dieses Verhalten:



int aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Es wird erwartet, dass das Ergebnis des Vergleichs von Referenzen falsch ist , da zwei Boxvorgänge aufgetreten sind und zwei Objekte erstellt wurden, auf die in obj1 und obj2 Verweise geschrieben wurden .



Ändern Sie nun int in Nullable <int> .



Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Das Ergebnis wird weiterhin erwartet - falsch .



Und jetzt schreiben wir anstelle von 62 den Standardwert.



Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Iii ... das Ergebnis ist plötzlich wahr . Es scheint, dass wir alle die gleichen 2 Packvorgänge haben, bei denen zwei Objekte und Verknüpfungen zu zwei verschiedenen Objekten erstellt werden, aber das Ergebnis ist wahr !



Ja, es ist wahrscheinlich wieder Zucker und auf IL-Code-Ebene hat sich etwas geändert! Mal sehen.



Beispiel N1.



C # -Code:



int aVal = 62;
object aObj = aVal;


IL-Code:



.locals init (int32 V_0,
              object V_1)

// aVal = 62
ldc.i4.s   62
stloc.0

//  aVal
ldloc.0
box        [System.Runtime]System.Int32

//     aObj
stloc.1


Beispiel N2.



C # -Code:



Nullable<int> aVal = 62;
object aObj = aVal;


IL-Code:



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              object V_1)

// aVal = new Nullablt<int>(62)
ldloca.s   V_0
ldc.i4.s   62
call       instance void
           valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)

//  aVal
ldloc.0
box        valuetype [System.Runtime]System.Nullable`1<int32>

//     aObj
stloc.1


Beispiel N3.



C # -Code:



Nullable<int> aVal = new Nullable<int>();
object aObj = aVal;


IL-Code:



.locals init (valuetype [System.Runtime]System.Nullable`1<int32> V_0,
              object V_1)

// aVal = new Nullable<int>()
ldloca.s   V_0
initobj    valuetype [System.Runtime]System.Nullable`1<int32>

//  aVal
ldloc.0
box        valuetype [System.Runtime]System.Nullable`1<int32>

//     aObj
stloc.1


Wie wir sehen können, erfolgt das Packen überall auf die gleiche Weise - die Werte lokaler Variablen werden auf den Auswertungsstapel geladen ( ldloc- Anweisung ). Danach erfolgt das Packen selbst durch Aufrufen des Befehls box , für den angegeben wird, welchen Typ wir tatsächlich packen werden.



Wir wenden uns der Common Language Infrastructure-Spezifikation zu , sehen uns die Beschreibung des Befehls box an und finden einen interessanten Hinweis zu nullbaren Typen:



Wenn typeTok ein Werttyp ist, konvertiert der Befehl box val in seine Boxform. ...Wenn es sich um einen nullbaren Typ handelt, wird dies durch Überprüfen der HasValue-Eigenschaft von val durchgeführt. Wenn es falsch ist, wird eine Nullreferenz auf den Stapel verschoben. Andernfalls wird das Ergebnis der Value-Eigenschaft von boxing val auf den Stapel verschoben.



Von hier aus gibt es mehrere Schlussfolgerungen, die das 'i' prägen:



  • Der Status des Nullable <T> -Objekts wird berücksichtigt (das zuvor berücksichtigte HasValue- Flag wird überprüft ). Wenn Nullable <T> keinen Wert enthält ( HasValue ist false ), führt das Feld zu null .
  • wenn Nullable <T> enthält den Wert ( HasValue - wahr ), dann nicht die Nullable <T> Objekt verpackt werden , sondern eine Instanz des T - Typs , der in dem gespeicherten Wert Feld der Nullable <T> Typs ;
  • Die spezifische Logik für die Behandlung von Nullable <T> -Paketen ist weder auf C # -Ebene noch auf IL-Ebene implementiert - sie ist in der CLR implementiert.


Kehren wir zu den oben diskutierten Nullable <T> -Beispielen zurück .



Zuerst:



Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Artikelzustand vor dem Verpacken:



  • T -> int ;
  • Wert -> 62 ;
  • hasValue -> true .


Der Wert 62 wird zweimal gepackt (denken Sie daran, dass in diesem Fall Instanzen vom Typ int gepackt werden und nicht Nullable <int> ), 2 neue Objekte erstellt werden, 2 Verweise auf verschiedene Objekte erhalten werden, deren Ergebnis falsch ist .



Zweite:



Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;

Console.WriteLine(Object.ReferenceEquals(obj1, obj2));


Artikelzustand vor dem Verpacken:



  • T -> int ;
  • value -> default (in diesem Fall ist 0 der Standardwert für int );
  • hasValue -> false .


Da hasValue ist falsch , werden keine Objekte auf dem Heap erstellt, und die Box - Operation kehrt null , die auf die Variablen geschrieben obj1 und obj2 . Der erwartete Vergleich dieser Werte ergibt true .



Im ursprünglichen Beispiel, das ganz am Anfang des Artikels stand, passiert genau dasselbe:



static void NullableTest()
{
  int? a = null;       // default value of Nullable<int>
  object aObj = a;     // null

  int? b = new int?(); // default value of Nullable<int>
  object bObj = b;     // null

  Console.WriteLine(Object.ReferenceEquals(aObj, bObj)); // null == null
}


Schauen wir uns zum Spaß den CoreCLR- Quellcode aus dem zuvor erwähnten Dotnet / Runtime- Repository an . Wir interessieren uns speziell für die Datei object.cpp - die Nullable :: Box- Methode , die die Logik enthält, die wir benötigen:



OBJECTREF Nullable::Box(void* srcPtr, MethodTable* nullableMT)
{
  CONTRACTL
  {
    THROWS;
    GC_TRIGGERS;
    MODE_COOPERATIVE;
  }
  CONTRACTL_END;

  FAULT_NOT_FATAL();      // FIX_NOW: why do we need this?

  Nullable* src = (Nullable*) srcPtr;

  _ASSERTE(IsNullableType(nullableMT));
  // We better have a concrete instantiation, 
  // or our field offset asserts are not useful
  _ASSERTE(!nullableMT->ContainsGenericVariables());

  if (!*src->HasValueAddr(nullableMT))
    return NULL;

  OBJECTREF obj = 0;
  GCPROTECT_BEGININTERIOR (src);
  MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
  obj = argMT->Allocate();
  CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);
  GCPROTECT_END ();

  return obj;
}


Hier ist alles, worüber wir oben gesprochen haben. Wenn wir den Wert nicht speichern, geben wir NULL zurück :



if (!*src->HasValueAddr(nullableMT))
    return NULL;


Ansonsten produzieren wir Verpackungen:



OBJECTREF obj = 0;
GCPROTECT_BEGININTERIOR (src);
MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
obj = argMT->Allocate();
CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);


Fazit



Aus Gründen des Interesses schlage ich vor, meinen Kollegen und Freunden ein Beispiel vom Anfang des Artikels zu zeigen. Werden sie in der Lage sein, die richtige Antwort zu geben und sie zu begründen? Wenn nicht, laden Sie sie ein, den Artikel zu lesen. Wenn sie können - nun, mein Respekt!



Ich hoffe, es war ein kleines, aber lustiges Abenteuer. :)



PS Jemand könnte eine Frage haben: Wie begann das Eintauchen in dieses Thema? Wir haben in PVS-Studio eine neue Diagnoseregel erstellt , die besagt , dass Object.ReferenceEquals mit Argumenten arbeitet, von denen eines durch einen signifikanten Typ dargestellt wird. Plötzlich stellte sich heraus, dass es bei Nullable <T> einen unerwarteten Moment im Packverhalten gibt. Wir haben die IL-Code- Box als Box betrachtet... Schauen Sie sich die CLI-Spezifikation an - ja, das war's! Es schien, dass dies ein ziemlich interessanter Fall ist, der es wert ist, einmal erzählt zu werden! - und der Artikel liegt vor Ihnen.





Wenn Sie diesen Artikel einem englischsprachigen Publikum zugänglich machen möchten, verwenden Sie bitte den Übersetzungslink: Sergey Vasiliev. Überprüfen Sie, wie Sie sich an nullbare Werttypen erinnern. Lass uns unter die Haube schauen .



PPS Übrigens war ich in letzter Zeit etwas aktiver auf Twitter, wo ich einige interessante Codefragmente poste, einige interessante Nachrichten aus der .NET-Welt retweetete und so etwas. Ich schlage vor, bei Interesse durchzusehen - abonnieren ( Link zum Profil ).



All Articles