首先,让我们谈谈引用类型和值类型。 我认为人们并不真正了解两者的区别和好处。 他们通常说引用类型将内容存储在堆中,而值类型将内容存储在堆栈中,这是错误的。
让我们讨论一下真正的区别:
- 值类型 :其值是一个整体结构 。 引用类型的值是对对象的引用 。 -内存中的结构:值类型仅包含您指定的数据。 引用类型还包含两个系统字段。 第一个存储“ SyncBlockIndex”,第二个存储有关类型的信息,包括有关虚拟方法表(VMT)的信息。
- 引用类型可以具有在继承时被覆盖的方法。 值类型不能被继承。
- 您应该在堆上为引用类型的实例分配空间。 值类型可以在堆栈上分配,也可以成为引用类型的一部分。 这足以提高某些算法的性能。
但是,有一些共同的功能:
让我们仔细看看每个功能。
本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。
另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分叉存储库
github / sidristij / dotnetbook

让我们仔细看看每个功能。
复制中
两种类型之间的主要区别如下:
- 带有引用类型的每个变量,类或结构字段或方法参数都存储对值的引用 ;
- 但是,采用值类型的每个变量,类或结构字段或方法参数都精确存储一个值,即整个结构。
这意味着将参数分配或传递给方法将复制该值。 即使您更改副本,原件也将保持不变。 但是,如果更改引用类型字段,这将通过引用类型实例来“影响”所有部件。 让我们看一下
例如:
DateTime dt = DateTime.Now;
似乎此属性会产生模棱两可的代码结构,例如
集合中代码的更改:
// Let's declare a structure struct ValueHolder { public int Data; } // Let's create an array of such structures and initialize the Data field = 5 var array = new [] { new ValueHolder { Data = 5 } }; // Let's use an index to get the structure and put 4 in the Data field array[0].Data = 4; // Let's check the value Console.WriteLine(array[0].Data);
这段代码有一个小技巧。 看起来我们首先获得了结构实例,然后将新值分配给副本的Data字段。 这意味着我们应该在检查值时再次得到5
。 但是,这不会发生。 MSIL有一个单独的指令,用于设置数组结构中的字段值,从而提高了性能。 该代码将按预期工作:该程序将
输出4
到控制台。
让我们看看如果更改此代码会发生什么:
// Let's declare a structure struct ValueHolder { public int Data; } // Let's create a list of such structures and initialize the Data field = 5 var list = new List<ValueHolder> { new ValueHolder { Data = 5 } }; // Let's use an index to get the structure and put 4 in the Data field list[0].Data = 4; // Let's check the value Console.WriteLine(list[0].Data);
该代码的编译将失败,因为当您写入list[0].Data = 4
您将首先获取该结构的副本。 实际上,您正在调用List<T>
类型的实例方法,该方法以索引为基础。 它从内部数组中获取结构的副本( List<T>
将数据存储在数组中),然后使用索引从访问方法中将此副本返回给您。 接下来,您尝试修改副本,以后将不再使用它。 这段代码毫无意义。 知道人们滥用值类型,编译器禁止这种行为。 我们应该通过以下方式重写此示例:
// Let's declare a structure struct ValueHolder { public int Data; } // Let's create a list of such structures and initialize the Data field = 5 var list = new List<ValueHolder> { new ValueHolder { Data = 5 } }; // Let's use an index to get the structure and put 4 in the Data field. Then, let's save it again. var copy = list[0]; copy.Data = 4; list[0] = copy; // Let's check the value Console.WriteLine(list[0].Data);
尽管有明显的冗余性,但该代码是正确的。 该计划将
输出4
到控制台。
下一个示例说明我的意思是“结构的值是
整个结构”
// Variant 1 struct PersonInfo { public int Height; public int Width; public int HairColor; } int x = 5; PersonInfo person; int y = 6; // Variant 2 int x = 5; int Height; int Width; int HairColor; int y = 6;
这两个示例在内存中的数据位置方面都相似,因为结构的值是整个结构。 它为所在的位置分配内存。
// Variant 1 struct PersonInfo { public int Height; public int Width; public int HairColor; } class Employee { public int x; public PersonInfo person; public int y; } // Variant 2 class Employee { public int x; public int Height; public int Width; public int HairColor; public int y; }
这些示例在元素在内存中的位置方面也相似,因为该结构在类字段中占据了已定义的位置。 我并不是说它们完全相似,因为您可以使用结构方法来操作结构字段。
当然,引用类型不是这种情况。 实例本身位于不可访问的小对象堆(SOH)或大对象堆(LOH)上。 类字段仅包含指向实例的指针的值:32或64位数字。
让我们看一下解决这个问题的最后一个例子。
// Variant 1 struct PersonInfo { public int Height; public int Width; public int HairColor; } void Method(int x, PersonInfo person, int y); // Variant 2 void Method(int x, int HairColor, int Width, int Height, int y);
就内存而言,这两种代码变体都将以相似的方式工作,但就架构而言却不会。 它不仅仅是替换可变数量的参数。 因为方法参数被一个接一个地声明,所以顺序改变了。 它们以类似的方式放在堆栈上。
但是,堆栈从较高的地址增长到较低的地址。 这意味着逐个推动结构的顺序与整体推动的顺序不同。
可覆盖的方法和继承
两种类型之间的下一个大区别是缺乏虚拟
结构中的方法表。 这意味着:
- 您不能描述和覆盖结构中的虚拟方法。
- 一个结构不能继承另一个结构。 模拟继承的唯一方法是在第一个字段中放置基类型结构。 “继承”结构的字段将追随“基础”结构的字段,并将创建逻辑继承。 这两个结构的字段将基于偏移量重合。
- 您可以将结构传递给非托管代码。 但是,您将丢失有关方法的信息。 这是因为结构只是内存中的空间,充满了没有类型信息的数据。 您可以将其传递给非托管方法,例如用C ++编写的方法,而无需进行任何更改。
缺少虚拟方法表会从结构中减去继承“魔力”的某些部分,但会给它们带来其他好处。 第一个是我们可以将这种结构的实例传递给外部环境(.NET Framework外部)。 记住,这只是一个回忆
范围! 我们还可以采用非托管代码的内存范围,并将类型转换为我们的结构以使其字段更易于访问。 您不能对类执行此操作,因为它们具有两个不可访问的字段。 这些是SyncBlockIndex和虚拟方法表地址。 如果这两个字段传递给非托管代码,将很危险。 使用虚拟方法表,可以访问任何类型并对其进行更改以攻击应用程序。
让我们展示它只是一个没有附加逻辑的内存范围。
unsafe void Main() { int secret = 666; HeightHolder hh; hh.Height = 5; WidthHolder wh; unsafe { // This cast wouldn't work if structures had the information about a type. // The CLR would check a hierarchy before casting a type and if it didn't find WidthHolder, // it would output an InvalidCastException exception. But since a structure is a memory range, // you can interpret it as any kind of structure. wh = *(WidthHolder*)&hh; } Console.WriteLine("Width: " + wh.Width); Console.WriteLine("Secret:" + wh.Secret); } struct WidthHolder { public int Width; public int Secret; } struct HeightHolder { public int Height; }
在这里,我们执行强类型打字中不可能的操作。 我们将一种类型转换为包含一个额外字段的另一种不兼容类型。 我们在Main方法中引入了一个附加变量。 从理论上讲,它的价值是秘密的。 但是,示例代码将输出在Main()
方法内部的任何结构中都找不到的变量的值。 您可能会认为这违反了安全性,但是事情并不是那么简单。 您无法摆脱程序中不受管理的代码。 主要原因是线程堆栈的结构。 可以使用它来访问非托管代码并使用局部变量。 您可以通过使堆栈帧的大小随机化来保护代码免受这些攻击。 或者,您可以删除有关EBP
寄存器的信息,以使堆栈帧的返回复杂化。 但是,现在这对我们来说已经无关紧要。 下面是我们对该示例感兴趣的内容。 “ secret”变量在hH变量的定义之前和之后在WidthHolder结构中(实际上在不同位置)之后。 那么为什么我们轻易获得它的价值呢? 答案是堆栈从右向左增长。 首先声明的变量将具有更高的地址,而之后声明的变量将具有更低的地址。
调用实例方法时的行为
两种数据类型都有另一个看不见的特征,可以解释两种类型的结构。 它处理调用实例方法。
// The example with a reference type class FooClass { private int x; public void ChangeTo(int val) { x = val; } } // The example with a value type struct FooStruct { private int x; public void ChangeTo(int val) { x = val; } } FooClass klass = new FooClass(); FooStruct strukt = new FooStruct(); klass.ChangeTo(10); strukt.ChangeTo(10);
从逻辑上讲,我们可以确定该方法具有一个已编译主体。 换句话说,没有类型的实例拥有自己的已编译方法集,这与其他实例集相似。 但是,被调用方法知道作为第一个参数,它属于哪个实例作为对类型实例的引用。 我们可以重写我们的示例,该示例将与之前所说的相同。 我没有故意使用虚拟方法的示例,因为它们有另一个过程。
// An example with a reference type class FooClass { public int x; } // An example with a value type struct FooStruct { public int x; } public void ChangeTo(FooClass klass, int val) { klass.x = val; } public void ChangeTo(ref FooStruct strukt, int val) { strukt.x = val; } FooClass klass = new FooClass(); FooStruct strukt = new FooStruct(); ChangeTo(klass, 10); ChangeTo(ref strukt, 10);
我应该解释ref关键字的用法。 如果不使用它,我将获得结构的副本作为方法参数,而不是原始参数。 然后,我将其更改,但原始版本将保持不变。 我将必须将更改后的副本从方法返回给调用者(另一种复制),并且调用者会将这个值保存回变量中(一次复制)。 相反,实例方法获取一个指针,并使用它立即更改原始指针。 使用指针不会影响性能,因为任何处理器级别的操作都使用指针。 Ref仅属于C#世界的一部分。
指向元素位置的能力。
结构和类都有另一种能力来指向特定字段相对于存储器中结构开始的偏移量。 这有几个目的:
- 在不受管理的世界中使用外部API,而不必在必要的字段之前插入未使用的字段;
- 指示编译器在(
[FieldOffset(0)]
)类型的开头定位字段。 它将使与此类型的工作更快。 如果它是一个经常使用的字段,我们可以提高应用程序的性能。 但是,这仅适用于值类型。 在引用类型中,偏移量为零的字段包含虚拟方法表的地址,该地址使用1个机器字。 即使您寻址一个类的第一个字段,它也会使用复杂的寻址(地址+偏移量)。 这是因为最常用的类字段是虚拟方法表的地址。 该表对于调用所有虚拟方法是必需的; - 使用一个地址指向多个字段。 在这种情况下,相同的值将解释为不同的数据类型。 在C ++中,此数据类型称为联合。
- 不要费心声明任何东西:编译器将优化分配字段。 因此,字段的最终顺序可能不同。
一般说明
- 自动 :运行时环境会自动为所有类或结构字段选择位置和包装。 该枚举的成员标记的已定义结构不能传递到非托管代码中。 尝试这样做会产生异常。
- 明确的 :程序员使用FieldOffsetAttribute显式控制类型的每个字段的确切位置;
- 顺序的 :类型成员按顺序排列,这是在类型设计期间定义的。 打包步骤的StructLayoutAttribute.Pack值指示其位置。
使用FieldOffset跳过未使用的结构字段
来自非托管世界的结构可以包含保留字段。 可以在库的将来版本中使用它们。 在C / C ++中,我们通过添加字段来填补这些空白,例如,reserved1,reserved2,...。但是,在.NET中,我们只是使用FieldOffsetAttribute属性和[StructLayout(LayoutKind.Explicit)]
偏移到字段的开头。
[StructLayout(LayoutKind.Explicit)] public struct SYSTEM_INFO { [FieldOffset(0)] public ulong OemId; // 92 bytes reserved [FieldOffset(100)] public ulong PageSize; [FieldOffset(108)] public ulong ActiveProcessorMask; [FieldOffset(116)] public ulong NumberOfProcessors; [FieldOffset(124)] public ulong ProcessorType; }
间隙被占用但未使用的空间。 该结构的大小等于132,而不是从一开始就看起来为40字节。
联盟
使用FieldOffsetAttribute可以模拟称为联合的C / C ++类型。 它允许访问与实体相同的数据
不同的类型。 让我们看一个例子:
// If we read the RGBA.Value, we will get an Int32 value accumulating all // other fields. // However, if we try to read the RGBA.R, RGBA.G, RGBA.B, RGBA.Alpha, we // will get separate components of Int32. [StructLayout(LayoutKind.Explicit)] public struct RGBA { [FieldOffset(0)] public uint Value; [FieldOffset(0)] public byte R; [FieldOffset(1)] public byte G; [FieldOffset(2)] public byte B; [FieldOffset(3)] public byte Alpha; }
您可能会说这种行为仅适用于值类型。 但是,您可以针对引用类型对它进行模拟,使用一个地址重叠两种引用类型或一种引用类型和一种值类型:
class Program { public static void Main() { Union x = new Union(); x.Reference.Value = "Hello!"; Console.WriteLine(x.Value.Value); } [StructLayout(LayoutKind.Explicit)] public class Union { public Union() { Value = new Holder<IntPtr>(); Reference = new Holder<object>(); } [FieldOffset(0)] public Holder<IntPtr> Value; [FieldOffset(0)] public Holder<object> Reference; } public class Holder<T> { public T Value; } }
我使用通用类型来故意重叠。 如果我平常使用
重叠,当在应用程序域中加载时,此类型将导致TypeLoadException。 从理论上讲,这看起来像是一个安全漏洞(特别是在谈论应用程序插件时 ),但是,如果我们尝试使用受保护的域来运行此代码,则会得到相同的TypeLoadException
。
分配差异
区分这两种类型的另一个功能是对象或结构的内存分配。 在为对象分配内存之前,CLR必须决定几件事。 一个物体的大小是多少? 是大于还是小于85K? 如果更少,那么SOH上是否有足够的可用空间来分配此对象? 如果更多,则CLR激活垃圾收集器。 它遍历对象图,通过将对象移动到空白空间来压缩对象。 如果SOH上仍然没有空间,则会开始分配其他虚拟内存页。 只有这样,对象才能获得分配的空间,并从垃圾中清除。 之后,CLR布置SyncBlockIndex和VirtualMethodsTable。 最后,对对象的引用返回给用户。
如果分配的对象大于85K,则转到大对象堆(LOH)。 大型字符串和数组就是这种情况。 在这里,我们必须从空闲范围列表中找到内存中最合适的空间,或者分配一个新的空间。 这不是很快,但是我们将仔细处理这种大小的对象。 另外,我们在这里不讨论它们。
RefTypes有几种可能的方案:
- RefType <85K,SOH上有空间:快速分配内存;
- RefType <85K,SOH上的空间用完了:内存分配非常慢;
- RefType> 85K,内存分配缓慢。
这种操作很少见,无法与ValTypes竞争。 值类型的内存分配算法不存在。 为值类型分配内存不会花费任何费用。 为这种类型分配内存时,唯一发生的事情就是将字段设置为null。 让我们看看为什么会发生这种情况:1.当在方法主体中声明一个变量时,结构的内存分配时间接近于零。 那是因为分配给局部变量的时间不取决于它们的数量。 2.如果将ValTypes分配为字段,则Reftypes将增加字段的大小。 值类型被完全分配,成为其一部分。 3.与复制的情况一样,如果将ValTypes作为方法参数传递,则会出现差异,具体取决于参数的大小和位置。
但是,这不会比将一个变量复制到另一个变量花费更多的时间。
在类或结构之间进行选择
让我们讨论两种类型的优缺点,并决定它们的使用场景。 一个经典的原则是,如果值类型不大于16个字节,并且在其生命周期内保持不变并且不被继承,则应选择一个值类型。 但是,选择正确的类型意味着要根据将来的使用情况从不同的角度对其进行审查。 我提出了三组标准:
- 基于类型系统架构,您的类型将在其中进行交互;
- 基于您作为系统程序员的方式来选择具有最佳性能的类型;
- 当别无选择时。
每个设计的功能都应反映其目的。 这不仅仅处理其名称或交互接口(方法,属性)。 可以使用体系结构考虑因素在值和引用类型之间进行选择。 让我们考虑为什么从类型系统系统的角度选择结构而不是类。
如果您设计的类型与状态无关,则表示其状态反映了过程或某物的值。 换句话说,类型的实例本质上是恒定的并且不可更改。 我们可以通过指示一些偏移量来基于该常量创建类型的另一个实例。 或者,我们可以通过指示其属性来创建一个新实例。 但是,我们不能更改它。 我并不是说结构是不可变的类型。 您可以更改其字段值。 此外,您可以使用ref参数将对结构的引用传递到方法中,并且退出该方法后将获得更改的字段。 我在这里谈论的是建筑感。 我将举几个例子。
- DateTime是一种封装时间概念的结构。 它以uint形式存储此数据,但可以访问时间的各个单独特征:年,月,日,小时,分钟,秒,毫秒,甚至处理器滴答。 但是,基于它封装的内容,它是不可更改的。 我们不能及时改变。 我不能过下一分钟,就好像那是我小时候最好的生日一样。 因此,如果选择数据类型,则可以选择带有只读接口的类,该类为每次属性更改生成一个新实例。 或者,我们可以选择一种结构,该结构可以但不应更改其实例的字段:其值是对时间的描述,例如数字。 您无法访问数字的结构并进行更改。 如果您想获得另一个时间(与原始时间相差一天),那么您将获得一个结构的新实例。
KeyValuePair<TKey, TValue>
是一种封装已连接键-值对的概念的结构。 此结构仅用于在枚举过程中输出字典的内容。 从体系结构的角度来看,键和值是Dictionary<T>
中不可分割的概念。 但是,在内部,我们有一个复杂的结构,其中键与值分开放置。 对于用户而言,键值对在界面和数据结构的意义上是不可分割的概念。 它本身就是一个完整的价值 。 如果为一个键分配另一个值,则整个对将改变。 因此,它们代表单个实体。 在这种情况下,这使结构成为理想的变体。
如果您设计的类型是外部类型的不可分割的一部分,但在结构上是不可分割的。 这意味着说外部类型是指封装类型的实例是不正确的。 但是,正确地说封装类型是外部及其所有属性的一部分。 在设计作为另一结构一部分的结构时,这很有用。
- 例如,如果我们采用文件头的结构,则将引用从一个文件传递到另一个文件(例如某些header.txt文件)是不合适的。 当将文档插入另一个文档而不是通过嵌入文件而是在文件系统中使用引用时,这将是适当的。 一个很好的例子是Windows OS中的快捷方式文件。 但是,如果我们谈论文件头(例如JPEG文件头,其中包含有关图像大小,压缩方法,摄影参数,GPS坐标等的元数据),则我们应该使用结构来设计用于解析头的类型。 如果在结构中描述所有标题,则字段在内存中的位置与在文件中的位置相同。 使用简单的不安全
*(Header *)readedBuffer
转换而不进行反序列化,您将获得完全填充的数据结构。
- 这两个示例都没有显示行为的继承。 它们表明没有必要继承这些实体的行为。 他们是独立的。 但是,如果考虑到代码的有效性,我们将从另一侧看到选择:
- 如果需要从非托管代码中获取一些结构化数据,则应选择结构。 我们还可以将数据结构传递给不安全的方法。 引用类型根本不适合此类型。
- 如果类型在方法调用中传递数据(作为返回值或方法参数),并且无需从不同位置引用相同的值,则您可以选择结构。 完美的例子是元组。 如果一个方法使用元组返回多个值,它将返回一个ValueTuple,声明为结构。 该方法不会在堆上分配空间,但会使用线程的堆栈,其中内存分配不会花费任何费用。
- 如果您设计的系统创建的实例流量较小且寿命较长,那么使用引用类型将导致对象池,或者如果没有对象池,则会导致堆上不受控制的垃圾堆积。 有些对象会变成更老的一代,从而增加GC的负担。 在这种情况下(如果可能)使用值类型将提高性能,因为不会传递给SOH。 这样可以减轻GC的负担,并且算法可以更快地运行。
根据我所说的内容,以下是有关使用结构的一些建议:
- 选择集合时,应避免大型数组存储大型结构。 这包括基于数组的数据结构。 这可能导致过渡到大对象堆及其碎片。 认为如果我们的结构有4个字节类型的字段会占用4个字节是错误的。 我们应该理解,在32位系统中,每个结构字段在4个字节的边界上对齐(每个地址字段应精确地除以4),而在64位系统中,则在8个字节边界上对齐。 数组的大小应取决于运行程序的结构和平台的大小。 在我们的示例中,有4个字节-85K /(每个字段从4到8个字节*字段数= 4)减去数组头的大小,大约等于每个数组2600个元素,具体取决于平台(应四舍五入) ) 那不是很多。 似乎我们可以轻松达到20,000个元素的魔术常数
- 有时,您使用大型结构作为数据源,并将其作为类中的字段放置,同时复制一个副本以生成一千个实例。 然后,针对结构的大小扩展类的每个实例。 这将导致第0代膨胀,并过渡到第1代甚至第2代。 如果某个类的实例的寿命很短,并且您认为GC将在第0代收集它们-持续1毫秒,您会感到失望。 他们已经是第一代甚至第二代了。 这有所作为。 如果GC在1毫秒内收集零代,则第一和第二代的收集非常缓慢,这将导致效率降低;否则,第一代和第二代将被缓慢收集。
- 出于同样的原因,您应该避免通过一系列方法调用传递大型结构。 如果所有元素互相调用,这些调用将占用堆栈上更多的空间,并通过StackOverflowException使您的应用程序死亡。 下一个原因是性能。 副本越多,一切工作就越慢。
这就是为什么选择数据类型不是一个显而易见的过程的原因。 通常,这可能是指过早的优化,不建议这样做。 但是,如果您知道自己的处境符合上述原则,则可以轻松选择值类型。
本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。
另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分叉存储库
github / sidristij / dotnetbook