您还记得可空值类型吗?我们看起来“在幕后”

image1.png


最近,可为空的引用类型已成为热门话题。但是,好的旧的可为空的值类型并未消失,仍在积极使用。您是否还记得与他们合作的细微差别?我建议您通过阅读本文来刷新或测试您的知识。包括示例C#和IL代码,对CLI规范的引用和CoreCLR代码。我建议从一个有趣的问题开始。



注意。如果您对可为空的引用类型感兴趣,可以查看一些同事的文章:“ C#8.0和静态分析中的可为空引用类型”,“可为空引用没有保护,这就是证明。”



查看下面的示例代码,并回答将输出到控制台的内容。而且,同样重要的是为什么。让我们立即同意您将按原样回答:无需编译器提示,文档,阅读文献或类似内容。 :)



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


好吧,让我们考虑一下。在我看来,让我们采取一些主要的思路。



1.从int事实出发吗? -参考类型。



让我们这样推理,什么是int?是引用类型。在这种情况下,值写入null,也将记录,并在赋值后aObj对某个对象引用将用b编写。分配后,它也将被写入bObj。结果,Object.ReferenceEquals将使用null和非null对象引用作为参数,因此...



显然,答案是False!



2.我们从int吗? -重要类型。



或者,也许您怀疑int? -参考类型?而且,尽管有int表达式,您是否还确定呢? a =空?好吧,让我们从另一端开始,从什么是int开始 -重要类型。



在这种情况下,表达式为int? a = null看起来有些奇怪,但是假设再次在C#中将糖倒在了上面。事实证明,a存储某种对象。b还存储某种对象。初始化变量aObjbObj时,存储在ab中的对象将被打包,因此将不同的引用写入aObjbObj。事实证明,Object.ReferenceEquals将对不同对象的引用作为参数,因此...



一切都是显而易见的,答案是False!



3.我们假设此处使用Nullable <T>



假设您不喜欢上面的选项。因为您完全了解没有int?实际上不是,但是有一个值类型Nullable <T>,在这种情况下将使用Nullable <int>。你也明白,实际上在ab会有相同的对象。同时,您不会忘记在将值写入aObjbObj时会发生打包,结果将获得对不同对象的引用。由于Object.ReferenceEquals接受对不同对象的引用,因此...



显而易见,答案为False!



4 .;)



对于那些从值类型入手的人-如果您突然对比较引用存有疑问,可以在docs.microsoft.com查看Object.ReferenceEquals上的文档。... 特别是,它还涉及值类型和装箱/拆箱的主题。的确,这里描述了将重要类型的实例直接传递给方法的情况,我们单独取出了包装,但本质是相同的。



比较值类型时。如果objA和objB是值类型,则将它们装箱,然后将它们传递给ReferenceEquals方法。这意味着,如果objA和objB都代表值类型的相同实例,则ReferenceEquals方法仍然返回false,如以下示例所示。



看来这里可以完成本文,但只有……正确的答案是True



好吧,让我们弄清楚。



理解



有两种方法-简单和有趣。



简单的方法



诠释?可空的<int>打开Nullable <T>文档,在其中查看“装箱和拆箱”部分。原则上就是这些-行为在此处进行了描述。但是,如果您需要更多详细信息,我邀请您走一条有趣的路。;)



有趣的方式



在此路径上,我们没有足够的文档。她描述了行为,但没有回答“为什么”的问题?



实际上什么是int?在适当的范围内?为什么会这样工作?IL代码是否使用其他命令?在CLR级别上,行为是否有所不同?还有其他魔术吗?



让我们开始解析int实体吗?记住基础知识,并逐步对原始案例进行分析。由于C#是一种相当“甜美”的语言,因此我们将定期引用IL代码来了解事物的本质(是的,C#文档今天不再是我们的做法)。



int?,可空<T>



在这里,我们将原则上研究可空值类型的基础知识(它们是什么,它们在IL中的编译内容,等等)。下一部分将讨论作业中的问题答案。



让我们看一段代码。



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


尽管在C#中这些变量的初始化看起来有所不同,但将为所有变量生成相同的IL代码。



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


如您所见,在C#中,所有内容都由内在的语法糖加香,因此您和我可以生活得更好,实际上:



  • 诠释?-重要类型。
  • 诠释?-与Nullable <int>相同IL代码与Nullable <int32>一起使用
  • 诠释?aVal = nullNullable <int> 相同aVal = new Nullable <int>()在IL中,这扩展为initobj语句,该语句在加载的地址执行默认初始化。


考虑以下代码:



int? aVal = 62;


我们找出了默认的初始化-我们在上面看到了相应的IL代码。当我们想将aVal初始化为62时会发生什么



让我们看一下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)


再次,没有什么复杂的事情-将地址aVal和值62加载到评估堆栈中,然后调用具有签名Nullable <T>(T)的构造函数也就是说,以下两个表达式将完全相同:



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


您可以通过再次查看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)


那检查呢?例如,以下代码实际上是什么样的?



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


是的,为了理解,让我们再次转到相应的IL代码。



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


正如你可能已经猜到了,也实在是没有-所发生的一切是对一个呼叫可空<T> .HasValue财产也就是说,就C#中的相同逻辑而言,可以按如下方式使用实体来更明确地编写。



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


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
}




让我们总结一下:



  • 可空值类型的实现是以Nullable <T>类型为代价的
  • 诠释?-实际上是通用值类型Nullable <T>的构造类型
  • 诠释?a = null-使用默认值初始化Nullable <int>类型的对象,此处实际上不存在null
  • if(a == null) -再次,没有null,存在对Nullable <T> .HasValue属性的调用


所述的源代码可空<T>类型一个-可在DOTNET /运行时储存库被视为,例如,在GitHub直接链接到源代码文件那里没有太多代码,所以为了您的兴趣,我建议您仔细阅读。从那里,您可以了解(或记住)以下事实。



为了方便起见,Nullable <T>类型定义:



  • TNullable <T>的隐式转换运算符
  • Nullable <T>T的显式转换运算符


工作的主要逻辑是通过两个字段(和相应的属性)实现的:



  • T value-值本身,在其上包裹着Nullable <T>
  • bool hasValue是一个标志,指示包装器是否包含值。用引号引起来,实际上Nullable <T>始终包含类型T的值


现在,我们对可空值类型进行了复习,让我们看一下打包的内容。



可空的<T>包装



让我提醒您,打包值类型的对象时,将在堆上创建一个新对象。下面的代码片段说明了此行为:



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

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


比较引用的结果应该是假的,因为发生了2次装箱操作并且创建了两个对象,对它们的引用都写在obj1obj2中



现在将int更改Nullable <int>



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

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


结果仍然是预期的-错误



现在,我们写入默认值,而不是62。



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

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


我...结果突然变成事实似乎我们具有相同的2个打包操作,创建了两个对象并链接到两个不同的对象,但是结果为true



是的,可能又是糖,而且IL代码级别上的某些内容已经改变!让我们来看看。



示例N1。



C#代码:



int aVal = 62;
object aObj = aVal;


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


实施例N2。



C#代码:



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


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


实施例N3。



C#代码:



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


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


如我们所见,打包在任何地方都以相同的方式完成-将局部变量的值加载到评估堆栈(ldloc指令)中,然后通过调用box命令进行打包本身,指示要打包的类型。



我们转向公共语言基础结构规范,查看box命令的描述,找到有关可空类型的有趣注释:



如果typeTok是值类型,则box指令将val转换为其盒装形式。如果是可空类型,则通过检查val的HasValue属性来完成;如果为false,则将空引用压入堆栈;否则为空。否则,将装箱val的Value属性的结果压入堆栈。



从这里有一些结论点缀“ i”:



  • Nullable <T>对象的状态考虑在内检查了我们之前考虑的HasValue标志)。如果Nullable <T>不包含值(HasValuefalse),则该将为null否则,结果为null
  • 如果Nullable <T>包含值(HasValue - true),则不打包Nullable <T>对象,而是打包一个T类型的实例,该实例存储在Nullable <T>类型value字段
  • 处理打包Nullable <T>的特定逻辑不是在C#级别甚至在IL级别实现的,而是在CLR中实现的。


让我们 回到上面讨论Nullable <T>示例



第一:



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

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


包装前物品状态:



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


值62被打包两次(请记住,在这种情况下,将打包int类型的实例,而不是Nullable <int>),创建了2个新对象,获得了对不同对象的2个引用,其结果为false



第二:



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

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


包装前物品状态:



  • T- > int ;
  • ->默认值(在这种情况下,0int的默认值);
  • hasValue- > false


由于hasValuefalse,因此不会在堆上创建任何对象,并且box操作将返回null,该将被写入变量obj1obj2如预期的那样比较这些值得出true



在本文开头的原始示例中,发生了完全相同的事情:



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
}


为了好玩,让我们来看一下前面提到的dotnet / runtime存储库中的CoreCLR源代码我们对object.cpp文件感兴趣,特别-Nullable :: Box方法,其中包含我们需要的逻辑:



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


这就是我们上面讨论的所有内容。如果不存储该值,则返回NULL



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


结论



为了感兴趣,我建议从本文开始向我的同事和朋友展示一个示例。他们将能够给出正确答案并证实它吗?如果没有,请他们阅读这篇文章。如果他们可以-好的,我的尊敬!



我希望这是一次小型但有趣的冒险。:)



PS有人可能有一个问题:如何开始沉浸在这个话题上?我们在PVS-Studio中针对Object.ReferenceEquals与参数一起工作这一事实制定了新的诊断规则,其中一个参数由有效类型表示。突然发现,使用Nullable <T>时,打包行为发生了意外的时刻。我们查看了IL代码-... 看一下CLI规范-是的,就是这样!看来这是一个非常有趣的案例,值得一提-一次!-文章就在您的面前。





如果您想与说英语的读者分享这篇文章,请使用翻译链接:Sergey Vasiliev。检查如何记住可为空的值类型。让我们在幕后偷看



PPS顺便说一下,最近,我在Twitter上变得更加活跃,我在这里发布了一些有趣的代码片段,转发了来自.NET世界的一些有趣消息,诸如此类。如果您有兴趣,我建议仔细阅读-订阅(指向个人资料的链接)。



All Articles