Récemment, les types de référence nullables sont devenus un sujet brûlant. Cependant, les bons vieux types de valeurs nullables n'ont pas disparu et sont toujours activement utilisés. Vous souvenez-vous bien des nuances de travailler avec eux? Je vous suggère de rafraîchir ou de tester vos connaissances en lisant cet article. Des exemples de code C # et IL, des références à la spécification CLI et au code CoreCLR sont inclus. Je propose de commencer par un problème intéressant.
Remarque . Si vous êtes intéressé par les types de référence Nullable, vous pouvez consulter certains des articles de mes collègues: " Types de référence Nullable dans C # 8.0 et analyse statique ", " Les références Nullable ne protègent pas et voici la preuve ."
Jetez un œil à l'exemple de code ci-dessous et répondez à ce qui sera affiché sur la console. Et, tout aussi important, pourquoi. Admettons-nous immédiatement que vous répondrez tel quel: sans conseils de compilation, documentation, lecture de littérature ou quelque chose du genre. :)
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?
}
Eh bien, réfléchissons un peu. Prenons quelques grandes lignes de pensée qui, me semble-t-il, peuvent surgir.
1. Partant du fait que int? - Type de référence.
Raisonnons comme ça, qu'est-ce que int? Est un type de référence. Dans ce cas, une valeur est écrite à null , elle sera également enregistrée et aObj après affectation. Une référence à un objet sera écrite en b . Il sera également écrit dans bObj après l'affectation. En conséquence, Object.ReferenceEquals prendra null et une référence d'objet non nulle comme arguments , donc ...
C'est évident, la réponse est False!
2. Nous partons du fait que int? - type significatif.
Ou peut-être doutez-vous de cet int? - Type de référence? Et en êtes-vous sûr malgré l'expression int? a = nul ? Eh bien, allons de l'autre côté et partons de ce qui est int? - type significatif.
Dans ce cas, l'expression int? a = null semble un peu étrange, mais supposons qu'à nouveau en C # du sucre ait été versé sur le dessus. Il se trouve que un magasin une sorte d'objet. b stocke également une sorte d'objet. Lors de l'initialisation des variables aObj et bObj , les objets stockés dans a et b seront compressés, à la suite de quoi différentes références seront écrites dans aObj et bObj . Il s'avère que Object.ReferenceEquals prend des références à différents objets comme arguments, donc ...
Tout est évident, la réponse est False!
3. Nous supposons que Nullable <T> est utilisé ici .
Disons que vous n'aimez pas les options ci-dessus. Parce que vous savez parfaitement qu'il n'y a pas d' int? en fait non, mais il existe un type de valeur Nullable <T> , et dans ce cas Nullable <int> sera utilisé . Vous comprenez aussi qu'en fait en a et bil y aura des objets identiques. Dans le même temps, vous n'avez pas oublié que lors de l'écriture de valeurs dans aObj et bObj , un compactage se produira et, par conséquent, des références à différents objets seront obtenues. Puisque Object.ReferenceEquals accepte des références à différents objets, alors ...
C'est évident, la réponse est False!
4 .;)
Pour ceux qui ont commencé à partir de types valeur - si vous avez soudainement des doutes sur la comparaison des références, vous pouvez consulter la documentation sur Object.ReferenceEquals à docs.microsoft.com... En particulier, il aborde également le thème des types de valeur et de l'emballage / déballage. Certes, cela décrit un cas où des instances de types significatifs sont passées directement à la méthode, nous avons sorti l'emballage séparément, mais l'essence est la même.
Lors de la comparaison des types de valeur. Si objA et objB sont des types valeur, ils sont encadrés avant d'être passés à la méthode ReferenceEquals. Cela signifie que si objA et objB représentent la même instance d'un type valeur , la méthode ReferenceEquals renvoie néanmoins false , comme le montre l'exemple suivant.
Il semblerait qu'ici l'article puisse être terminé, mais seulement ... la bonne réponse est Vrai .
Eh bien, découvrons-le.
Compréhension
Il existe deux façons: simple et intéressante.
Le moyen facile
int? Est Nullable <int> . Ouvrez le Nullable <T> documentation , où nous regardons la section « boxe et unboxing ». En principe, c'est tout - le comportement y est décrit. Mais si vous voulez plus de détails, je vous invite sur un chemin intéressant. ;)
Manière intéressante
Nous n'aurons pas assez de documentation sur ce chemin. Elle décrit le comportement mais ne répond pas à la question «pourquoi»?
Qu'est-ce qu'un int en fait ? et nul dans le contexte approprié? Pourquoi ça marche comme ça? Le code IL utilise-t-il des commandes différentes ou non? Le comportement est-il différent au niveau du CLR? Une autre magie?
Commençons par analyser l'entité int? pour se souvenir des bases et passer progressivement à l'analyse du cas d'origine. Puisque C # est un langage plutôt "succulent", nous nous référerons périodiquement au code IL pour regarder l'essence des choses (oui, la documentation C # n'est pas notre manière aujourd'hui).
int?, Nullable <T>
Ici, nous allons examiner les bases des types valeur nullable en principe (ce qu'ils sont, ce qu'ils compilent en IL, etc.). La réponse à la question du devoir est discutée dans la section suivante.
Regardons un morceau de code.
int? aVal = null;
int? bVal = new int?();
Nullable<int> cVal = null;
Nullable<int> dVal = new Nullable<int>();
Bien que l'initialisation de ces variables soit différente en C #, le même code IL sera généré pour toutes.
.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>
Comme vous pouvez le voir, en C # tout est épicé avec du sucre syntaxique du cœur pour que vous et moi puissions vivre mieux, en fait:
- int? - type significatif.
- int? - identique à Nullable <int>. Le code IL fonctionne avec Nullable <int32> .
- int? aVal = null est identique à Nullable <int> aVal = new Nullable <int> () . En IL, cela se développe dans une instruction initobj qui effectue l'initialisation par défaut à l'adresse chargée.
Considérez le morceau de code suivant:
int? aVal = 62;
Nous avons compris l'initialisation par défaut - nous avons vu le code IL correspondant ci-dessus. Que se passe-t-il ici lorsque nous voulons initialiser aVal à 62?
Jetons un coup d'œil au code IL:
.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)
Encore une fois, rien de compliqué - l'adresse aVal est chargée sur la pile d'évaluation , ainsi que la valeur 62, après quoi le constructeur avec la signature Nullable <T> (T) est appelé . Autrement dit, les deux expressions suivantes seront complètement identiques:
int? aVal = 62;
Nullable<int> bVal = new Nullable<int>(62);
Vous pouvez voir la même chose en regardant à nouveau le code IL:
// 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)
Qu'en est-il des inspections? Par exemple, à quoi ressemble réellement le code suivant?
bool IsDefault(int? value) => value == null;
C'est vrai, pour comprendre, revenons au code IL correspondant.
.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
}
Comme vous l'avez peut-être deviné, il n'y a vraiment pas de null - tout ce qui se passe est un appel à la propriété Nullable <T> .HasValue . Autrement dit, la même logique en C # peut être écrite plus explicitement en termes d'entités utilisées comme suit.
bool IsDefaultVerbose(Nullable<int> value) => !value.HasValue;
Code IL:
.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
}
Résumons:
- Les types de valeur Nullable sont implémentés au détriment du type Nullable <T> ;
- int? - en fait le type construit du type valeur générique Nullable <T> ;
- int? a = null - initialisation d'un objet de type Nullable <int> avec la valeur par défaut, il n'y a en fait pas de null ici;
- if (a == null) - encore une fois, il n'y a pas de null , il y a un appel à la propriété Nullable <T> .HasValue .
Le code source du type Nullable <T> peut être visualisé, par exemple, sur GitHub dans le référentiel dotnet / runtime - un lien direct vers le fichier avec le code source . Il n'y a pas beaucoup de code, donc par souci d'intérêt, je vous conseille de regarder à travers. De là, vous pouvez apprendre (ou vous souvenir) des faits suivants.
Pour plus de commodité, le type Nullable <T> définit:
- opérateur de conversion implicite de T en Nullable <T> ;
- opérateur explicite de conversion de Nullable <T> à T .
La principale logique de travail est mise en œuvre à travers deux champs (et les propriétés correspondantes):
- Valeur T - la valeur elle-même, enveloppée sur laquelle est Nullable <T> ;
- bool hasValue est un indicateur indiquant si le wrapper contient une valeur. Entre guillemets, comme en fait Nullable <T> contient toujours une valeur de type T .
Maintenant que nous avons un rappel sur les types de valeur Nullable, voyons ce qui se passe avec le packaging.
Emballage Nullable <T>
Permettez-moi de vous rappeler que lors du conditionnement d'un objet d'un type valeur, un nouvel objet sera créé sur le tas. L'extrait de code suivant illustre ce comportement:
int aVal = 62;
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
On s'attend à ce que le résultat de la comparaison des références soit faux , puisque 2 opérations d' encadrement ont eu lieu et que deux objets ont été créés, dont les références ont été écrites dans obj1 et obj2 .
Maintenant, changez int en Nullable <int> .
Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
Le résultat est toujours attendu - faux .
Et maintenant, au lieu de 62, nous écrivons la valeur par défaut.
Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
Iii ... le résultat est soudainement vrai . Il semblerait que nous ayons tout de même 2 opérations de conditionnement, créant deux objets et des liens vers deux objets différents, mais le résultat est vrai !
Ouais, c'est probablement encore du sucre, et quelque chose a changé au niveau du code IL! Voyons voir.
Exemple N1.
Code C #:
int aVal = 62;
object aObj = aVal;
Code IL:
.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
Exemple N2.
Code C #:
Nullable<int> aVal = 62;
object aObj = aVal;
Code IL:
.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
Exemple N3.
Code C #:
Nullable<int> aVal = new Nullable<int>();
object aObj = aVal;
Code IL:
.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
Comme nous pouvons le voir, le conditionnement se fait de la même manière partout - les valeurs des variables locales sont chargées sur la pile d'évaluation (instruction ldloc ), après quoi le conditionnement lui-même a lieu en appelant la commande box , pour laquelle il est indiqué quel type nous allons empaqueter.
Nous passons à la spécification Common Language Infrastructure , examinons la description de la commande box et trouvons une note intéressante concernant les types Nullable:
Si typeTok est un type valeur, l'instruction box convertit val en sa forme encadrée. ...S'il s'agit d'un type Nullable, cela se fait en inspectant la propriété HasValue de val; s'il est faux, une référence nulle est poussée sur la pile; sinon, le résultat de la propriété Value de boxing val est poussé sur la pile.
De là, il y a plusieurs conclusions qui parsèment le 'i':
- l'état de l'objet Nullable <T> est pris en compte (le flag HasValue que nous avons considéré précédemment est vérifié ). Si Nullable <T> ne contient pas de valeur ( HasValue est false ), la boîte se traduira par null ;
- si Nullable <T> contient la valeur ( HasValue - true ), alors pas l'objet Nullable <T> ne sera compressé , mais une instance du type T , qui est stockée dans le champ de valeur du type Nullable <T> ;
- la logique spécifique pour la gestion de l'emballage Nullable <T> n'est pas implémentée au niveau C # ou même au niveau IL - elle est implémentée dans le CLR.
Revenons aux exemples Nullable <T> discutés ci-dessus.
Première:
Nullable<int> aVal = 62;
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
État de l'article avant l'emballage:
- T -> int ;
- valeur -> 62 ;
- hasValue -> true .
La valeur 62 est compressée deux fois (rappelez-vous que dans ce cas, les instances de type int sont compressées , et non Nullable <int> ), 2 nouveaux objets sont créés, 2 références à des objets différents sont obtenues, le résultat est faux .
Seconde:
Nullable<int> aVal = new Nullable<int>();
object obj1 = aVal;
object obj2 = aVal;
Console.WriteLine(Object.ReferenceEquals(obj1, obj2));
État de l'article avant l'emballage:
- T -> int ;
- value -> default (dans ce cas, 0 est la valeur par défaut pour int );
- hasValue -> false .
Puisque hasValue est false , aucun objet n'est créé sur le tas et l'opération box renvoie null , qui est écrite dans les variables obj1 et obj2 . La comparaison de ces valeurs, comme prévu, donne vrai .
Dans l'exemple d'origine, qui était au tout début de l'article, exactement la même chose se produit:
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
}
Pour le plaisir, jetons un coup d'œil au code source CoreCLR du référentiel dotnet / runtime mentionné précédemment . Nous nous intéressons au fichier object.cpp , en particulier - la méthode Nullable :: Box , qui contient la logique dont nous avons besoin:
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;
}
Voici tout ce dont nous avons parlé ci-dessus. Si nous ne stockons pas la valeur, nous retournons NULL :
if (!*src->HasValueAddr(nullableMT))
return NULL;
Sinon, nous produisons des emballages:
OBJECTREF obj = 0;
GCPROTECT_BEGININTERIOR (src);
MethodTable* argMT = nullableMT->GetInstantiation()[0].AsMethodTable();
obj = argMT->Allocate();
CopyValueClass(obj->UnBox(), src->ValueAddr(nullableMT), argMT);
Conclusion
Par souci d'intérêt, je propose de montrer un exemple du début de l'article à mes collègues et amis. Seront-ils en mesure de donner la bonne réponse et de la justifier? Sinon, invitez-les à lire l'article. S'ils le peuvent - eh bien, mon respect!
J'espère que c'était une petite mais amusante aventure. :)
PS Quelqu'un pourrait avoir une question: comment a commencé l'immersion dans ce sujet? Nous avons créé une nouvelle règle de diagnostic dans PVS-Studio sur le fait que Object.ReferenceEquals fonctionne avec des arguments, dont l'un est représenté par un type significatif. Soudain, il s'est avéré qu'avec Nullable <T>, il y avait un moment inattendu dans le comportement d'emballage. Nous avons regardé le code IL - box as box... Jetez un œil à la spécification CLI - ouais, c'est tout! Il semble que ce soit un cas assez intéressant, qui vaut la peine d'être raconté - une fois! - et l'article est devant vous.
Si vous souhaitez partager cet article avec un public anglophone, veuillez utiliser le lien de traduction: Sergey Vasiliev. Vérifiez comment vous vous souvenez des types de valeur Nullable. Jetons un coup d'œil sous le capot .
PPS En passant, récemment, j'ai été un peu plus actif sur Twitter, où je poste des extraits de code intéressants, retweet des nouvelles intéressantes du monde .NET et quelque chose du genre. Je propose de regarder à travers, si vous êtes intéressé - abonnez-vous ( lien vers le profil ).