
对象的基本类型和接口的实现。 装箱
看来我们经历了艰辛和艰辛,并且可以完成任何面试,甚至是.NET CLR团队的面试。 但是,我们不要急于访问microsoft.com并搜索职位空缺。 现在,我们需要了解如果值类型既不包含对SyncBlockIndex的引用,又不包含指向虚拟方法表的指针,则它们如何继承对象。 这将完全解释我们的类型系统,所有难题都将找到自己的位置。 但是,我们将需要一个以上的句子。
现在,让我们再次记住值类型如何在内存中分配。 他们将它们放在内存中的正确位置。 引用类型在大小对象堆上分配。 他们总是引用对象在堆上的位置。 每个值类型都具有ToString,Equals和GetHashCode之类的方法。 它们是虚拟的并且可以重写,但是不允许通过重写方法继承值类型。 如果值类型使用可覆盖的方法,则它们将需要一个虚拟方法表来路由调用。 这将导致将结构传递到不受管理的世界的问题:多余的字段将进入那里。 结果,某处有值类型方法的描述,但是您不能直接通过虚拟方法表访问它们。
这可能带来这样的想法,即缺乏继承是人为的
本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。
另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分叉存储库
github / sidristij / dotnetbook
这可能带来缺乏继承是人为的想法:
- 从对象继承,但不是直接继承;
- 基本类型中有ToString,Equals和GetHashCode。 在值类型中,这些方法具有自己的行为。 这意味着,相对于
object
,方法被重写; - 此外,如果将类型强制转换为
object
,则具有调用ToString,Equals和GetHashCode的完整权限; - 当为值类型调用实例方法时,该方法将获得另一个结构,该结构是原始副本。 这意味着调用实例方法就像调用静态方法:
Method(ref structInstance, newInternalFieldValue)
。 实际上,此调用通过this
,但是有一个例外。 JIT应该编译方法的主体,因此不需要偏移结构字段,而将指针跳过指向结构中不存在的虚拟方法表的指针。 它存在于另一个地方的值类型 。
类型在行为上是不同的,但是在CLR中的实现级别上,差别并不大。 我们稍后再讨论。
让我们在程序中编写以下行:
var obj = (object)10;
这将使我们能够使用基类处理数字10。 这称为拳击。 这意味着我们有一个VMT来调用诸如ToString(),Equals和GetHashCode之类的虚拟方法。 实际上,装箱会创建值类型的副本,但不会创建原始指针。 这是因为我们可以将原始值存储在任何地方:在堆栈上或作为类的字段。 如果将其强制转换为对象类型,则可以根据需要存储对此值的引用。 发生拳击时:
- CLR在堆上为一个值类型的结构+ SyncBlockIndex + VMT分配空间(以调用ToString,GetHashCode,Equals);
- 它在那里复制值类型的实例。
现在,我们有了一个值类型的引用变体。 结构具有与引用类型绝对相同的系统字段集 ,
装箱后成为完整的参考类型。 该结构成为一类。 我们称它为.NET翻筋斗。 这是一个公平的名字。
只要看看使用使用同一接口实现接口的结构会发生什么。
struct Foo : IBoo { int x; void Boo() { x = 666; } } IBoo boo = new Foo(); boo.Boo();
当我们创建Foo实例时,其值实际上进入堆栈。 然后,我们将此变量放入接口类型变量,并将结构放入引用类型变量。 接下来,有拳击,我们将对象类型作为输出。 但这是一个接口类型变量。 这意味着我们需要类型转换。 因此,呼叫以这种方式发生:
IBoo boo = (IBoo)(box_to_object)new Foo(); boo.Boo();
编写此类代码无效。 您将不得不更改副本而不是原始副本:
void Main() { var foo = new Foo(); foo.a = 1; Console.WriteLite(foo.a); // -> 1 IBoo boo = foo; boo.Boo(); // looks like changing foo.a to 10 Console.WriteLite(foo.a); // -> 1 } struct Foo: IBoo { public int a; public void Boo() { a = 10; } } interface IBoo { void Boo(); }
第一次查看代码时, 除了我们自己的代码外,我们不必了解我们在代码中处理的内容, 而是看到了IBoo接口的强制转换。 这使我们认为Foo是一个类,而不是一个结构。 则在结构和类中没有视觉上的划分,这使我们认为
接口修改结果必须进入foo,因为boo是foo的副本,所以不会发生。 那是误导。 我认为,此代码应获得注释,以便其他开发人员可以处理它。
第二件事与先前的想法有关,我们可以将类型从对象强制转换为IBoo。 这是盒装值类型是值类型的引用变体的另一种证明。 或者,类型系统中的所有类型都是引用类型。 我们可以像使用值类型一样使用结构,完全传递它们的值。 取消引用对象的指针,就像在C ++世界中所说的那样。
您可以反对,如果它是真的,它将看起来像这样:
var referenceToInteger = (IInt32)10;
我们不仅会得到一个对象,而且还会得到一个盒装值类型的类型化引用。 它将破坏价值类型的整体思想(即价值的完整性),从而允许基于其属性进行优化。 让我们放弃这个想法!
public sealed class Boxed<T> { public T Value; [MethodImpl(MethodImplOptions.AggressiveInlining)] public override bool Equals(object obj) { return Value.Equals(obj); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public override string ToString() { return Value.ToString(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public override int GetHashCode() { return Value.GetHashCode(); } }
我们有一个完整的拳击模拟。 但是,我们可以通过调用实例方法来更改其内容。 这些更改将影响对此数据结构的引用的所有部分。
var typedBoxing = new Boxed<int> { Value = 10 }; var pureBoxing = (object)10;
第一个变体不是很吸引人。 与创建类型无关,我们创建废话。 第二行要好得多,但是这两行几乎是相同的。 唯一的区别是在堆上分配内存后,在通常的装箱过程中不会将内存清零。 必要的结构可立即带走内存,而第一个变体需要清洁。 这使其工作时间比通常的拳击长10%。
相反,我们可以为盒装值调用一些方法。
struct Foo { public int x; public void ChangeTo(int newx) { x = newx; } } var boxed = new Boxed<Foo> { Value = new Foo { x = 5 } }; boxed.Value.ChangeTo(10); var unboxed = boxed.Value;
我们有一种新乐器。 让我们考虑一下我们可以做什么。
- 我们的
Boxed<T>
类型与通常的类型相同:在堆上分配内存,在堆中传递值,并通过执行unbox来获取它; - 如果您丢失对盒装结构的引用,GC将收集它;
- 但是,我们现在可以使用盒装类型,即调用其方法。
- 同样,我们可以将SOH / LOH中值类型的实例替换为另一个。 我们以前做不到,因为我们需要拆箱,将结构更改为另一个,然后再装箱,为客户提供新的参考。
装箱的主要问题是在内存中创建流量。 数量未知的对象的流量,其中一部分可以保留到第一代,在此期间我们遇到了垃圾回收问题。 将会有很多垃圾,我们本来可以避免的。 但是,当我们有短暂对象的流量时,第一个解决方案是池化。 这是.NET筋斗的理想终点。
var pool = new Pool<Boxed<Foo>>(maxCount:1000); var boxed = pool.Box(10); boxed.Value=70; // use boxed value here pool.Free(boxed);
现在,可以使用池进行装箱,从而消除了装箱时的内存流量。 我们甚至可以通过终结方法使对象恢复生命并将它们放回池中。 当盒装结构转到除您之外的其他异步代码并且您不知道何时不需要它时,这可能很有用。 在这种情况下,它将在GC期间返回到池中。
让我们总结一下:
- 如果拳击是偶然的,不应该发生,则不要使其发生。 这可能会导致性能问题。
- 如果装箱对于系统的体系结构是必需的,则可能会有变体。 如果装箱结构的流量很小并且几乎看不见,则可以使用装箱。 如果可见流量,则可能要使用上述解决方案之一对拳击进行汇总。 它花费了一些资源,但使GC可以正常工作。
最终,让我们看一个完全不切实际的代码:
static unsafe void Main() { // here we create boxed int object boxed = 10; // here we get the address of a pointer to a VMT var address = (void**)EntityPtr.ToPointerWithOffset(boxed); unsafe { // here we get a Virtual Methods Table address var structVmt = typeof(SimpleIntHolder).TypeHandle.Value.ToPointer(); // change the VMT address of the integer passed to Heap into a VMT SimpleIntHolder, turning Int into a structure *address = structVmt; } var structure = (IGetterByInterface)boxed; Console.WriteLine(structure.GetByInterface()); } interface IGetterByInterface { int GetByInterface(); } struct SimpleIntHolder : IGetterByInterface { public int value; int IGetterByInterface.GetByInterface() { return value; } }
该代码使用一个小的函数,该函数可以从对对象的引用中获取指针。 该库位于github地址 。 此示例表明,普通拳击将int转换为类型化引用类型。 走吧
查看过程中的步骤:
- 做一个整数拳击。
- 获取获得的对象的地址(Int32 VMT的地址)
- 获取SimpleIntHolder的VMT
- 将装箱整数的VMT替换为结构的VMT。
- 使拆箱成为结构类型
- 在屏幕上显示字段值,获取Int32,即
盒装。
我故意通过界面来做,因为我想证明它可以工作
那边
可空\ <T>
值得一提的是具有Nullable值类型的装箱行为。 Nullable值类型的此功能非常吸引人,因为某种类型的null的装箱返回null。
int? x = 5; int? y = null; var boxedX = (object)x; // -> 5 var boxedY = (object)y; // -> null
这导致我们得出一个奇怪的结论:由于null没有类型,因此
与框式不同的唯一获取类型的方法如下:
int? x = null; var pseudoBoxed = (object)x; double? y = (double?)pseudoBoxed;
该代码起作用只是因为您可以将类型强制转换为任何您喜欢的类型
与null。
在拳击方面更深入
最后,我想告诉您有关System.Enum type的信息 。 从逻辑上讲,这应该是一个值类型,因为它是通常的枚举:将数字别名为编程语言中的名称。 但是,System.Enum是引用类型。 在您的字段以及.NET Framework中定义的所有枚举数据类型都继承自System.Enum。 这是一个类数据类型。 而且,它是一个继承自System.ValueType
的抽象类。
[Serializable] [System.Runtime.InteropServices.ComVisible(true)] public abstract class Enum : ValueType, IComparable, IFormattable, IConvertible { // ... }
这是否意味着所有枚举都分配在SOH上,并且在使用它们时,我们会使堆和GC重载? 实际上不,因为我们只是使用它们。 然后,我们假设某个地方有一个枚举池,我们只获取它们的实例。 不,再说一次。 封送处理时,可以在结构中使用枚举。 枚举是通常的数字。
事实是,如果有枚举将类转换为值类型 ,则CLR在形成数据类型结构时会对其进行破解:
// Check to see if the class is a valuetype; but we don't want to mark System.Enum // as a ValueType. To accomplish this, the check takes advantage of the fact // that System.ValueType and System.Enum are loaded one immediately after the // other in that order, and so if the parent MethodTable is System.ValueType and // the System.Enum MethodTable is unset, then we must be building System.Enum and // so we don't mark it as a ValueType. if(HasParent() && ((g_pEnumClass != NULL && GetParentMethodTable() == g_pValueTypeClass) || GetParentMethodTable() == g_pEnumClass)) { bmtProp->fIsValueClass = true; HRESULT hr = GetMDImport()->GetCustomAttributeByName(bmtInternal->pType->GetTypeDefToken(), g_CompilerServicesUnsafeValueTypeAttribute, NULL, NULL); IfFailThrow(hr); if (hr == S_OK) { SetUnsafeValueClass(); } }
为什么要这样做? 特别是,由于继承的想法-要进行自定义的枚举,例如,您需要指定可能值的名称。 但是,不可能继承值类型。 因此,开发人员将其设计为引用类型,可以在编译时将其转换为值类型。
如果您想亲自看拳击怎么办?
幸运的是,您不必使用反汇编程序就可以进入代码丛林。 我们拥有整个.NET平台核心的内容,其中许多内容在.NET Framework CLR和CoreCLR方面是相同的。 您可以单击下面的链接,立即查看拳击的实施情况:
在这里,唯一的方法用于拆箱:
JIT_Unbox(..) ,它是JIT_Unbox_Helper(..)的包装。
另外,有趣的是( https://stackoverflow.com/questions/3743762/unboxing-does-not-create-a-copy-of-the-value-is-this-right ),取消装箱并不意味着复制数据到堆。 装箱意味着在测试类型的兼容性时将指针传递给堆。 拆箱后的IL操作码将使用该地址定义操作。 数据可能被复制到局部变量或堆栈中以调用方法。 否则,我们将进行双重复印; 首先是从堆复制到某个地方,然后再复制到目标位置。
问题
为什么.NET CLR无法为装箱本身进行合并?
如果我们与任何Java开发人员交谈,我们将了解两件事:
- Java中的所有值类型都被装箱,这意味着它们本质上不是值类型。 整数也被装箱。
- 出于优化的原因,从-128到127的所有整数均取自对象池。
那么,为什么在装箱期间在.NET CLR中不会发生这种情况? 很简单 因为我们可以更改盒装值类型的内容,所以可以执行以下操作:
object x = 1; x.GetType().GetField("m_value", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(x, 138); Console.WriteLine(x); // -> 138
或像这样(C ++ / CLI):
void ChangeValue(Object^ obj) { Int32^ i = (Int32^)obj; *i = 138; }
如果我们处理池化,那么我们会将应用程序中的所有池都更改为138,这不好。
接下来是.NET中值类型的本质。 他们重视价值,这意味着他们工作得更快。 拳击是罕见的,加上盒装数字属于幻想和不良建筑的世界。 这根本没有用。
为什么在调用采用对象类型(实际上是值类型)的方法时不能在堆栈而不是堆上进行装箱?
如果值类型装箱在堆栈上完成,并且引用将进入堆,则方法内部的引用可以移至其他位置,例如,方法可以将引用放入类的字段中。 然后该方法将停止,并且进行拳击的方法也将停止。 结果,引用将指向堆栈上的死区。
为什么不能将“值类型”用作字段?
有时我们想将一个结构用作另一个使用第一个结构的结构的字段。 或更简单:将结构用作结构字段。 不要问我为什么这会有用。 它不能。 如果将结构用作其字段或通过与其他结构的依赖关系,则会创建递归,这意味着无限大小的结构。 但是,.NET Framework在某些地方可以执行此操作。 一个示例是System.Char
, 其中包含自身 :
public struct Char : IComparable, IConvertible { // Member Variables internal char m_value; //... }
所有CLR基本类型都是以这种方式设计的。 我们,凡人,无法实现这种行为。 而且,我们不需要这样做:这样做是为了使原始类型具有CLR中的OOP精神。
该章程由专业翻译人员从俄语译为作者的语言 。 您可以帮助我们使用俄语和英语版本的文本作为源来创建该文本到其他任何语言(包括中文或德语)的翻译版本。
另外,如果您想说“谢谢”,那么您可以选择的最好方法是在github或fork库上给我们加星号
https://github.com/sidristij/dotnetbook