
最近,可为空的引用类型已成为热门话题。但是,好的旧的可为空的值类型并未消失,仍在积极使用。您是否还记得与他们合作的细微差别?我建议您通过阅读本文来刷新或测试您的知识。包括示例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?
}

好吧,让我们考虑一下。在我看来,让我们采取一些主要的思路。
1.从int事实出发吗? -参考类型。
让我们这样推理,什么是int?是引用类型。在这种情况下,将值写入null,也将记录该值,并在赋值后aObj。对某个对象的引用将用b编写。分配后,它也将被写入bObj。结果,Object.ReferenceEquals将使用null和非null对象引用作为参数,因此...
显然,答案是False!
2.我们从int吗? -重要类型。
或者,也许您怀疑int? -参考类型?而且,尽管有int表达式,您是否还确定呢? a =空?好吧,让我们从另一端开始,从什么是int开始? -重要类型。
在这种情况下,表达式为int? a = null看起来有些奇怪,但是假设再次在C#中将糖倒在了上面。事实证明,a存储某种对象。b还存储某种对象。初始化变量aObj和bObj时,存储在a和b中的对象将被打包,因此将不同的引用写入aObj和bObj。事实证明,Object.ReferenceEquals将对不同对象的引用作为参数,因此...
一切都是显而易见的,答案是False!
3.我们假设此处使用Nullable <T>。
假设您不喜欢上面的选项。因为您完全了解没有int?实际上不是,但是有一个值类型Nullable <T>,在这种情况下将使用Nullable <int>。你也明白,实际上在a和b会有相同的对象。同时,您不会忘记在将值写入aObj和bObj时会发生打包,结果将获得对不同对象的引用。由于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 = null与Nullable <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>类型定义:
- 从T到Nullable <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次装箱操作并且创建了两个对象,对它们的引用都写在obj1和obj2中。
现在将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>不包含值(HasValue为false),则该框将为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 ;
- 值->默认值(在这种情况下,0是int的默认值);
- hasValue- > false。
由于hasValue为false,因此不会在堆上创建任何对象,并且box操作将返回null,该值将被写入变量obj1和obj2。如预期的那样比较这些值得出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世界的一些有趣消息,诸如此类。如果您有兴趣,我建议仔细阅读-订阅(指向个人资料的链接)。