何时使用 struct?

什么时候应该在 C#中使用 struct 而不是 class?我的概念模型是当项目仅仅是值类型的集合时使用结构。一种逻辑上将它们组合在一起的方法。

我在这里遇到了这些规则:

  • 结构应代表单个值。
  • 结构的内存占用量应少于 16 个字节。
  • 创建后不应更改结构。

这些规则有效吗?结构在语义上是什么意思?

答案

OP 引用的来源具有一定的信誉... 但是,Microsoft 呢?对结构使用的立场是什么?我从 Microsoft寻求一些额外的学习 ,这是我发现的:

如果类型的实例较小且通常为短寿命或通常嵌入在其他对象中,请考虑定义结构而不是类。

除非类型具有以下所有特征,否则请不要定义结构:

  1. 它在逻辑上表示一个值,类似于基本类型(整数,双精度型,等等)。
  2. 它的实例大小小于 16 个字节。
  3. 这是一成不变的。
  4. 不必经常装箱。

Microsoft 一直违反这些规则

好吧,还是#2 和#3 我们钟爱的字典有 2 种内部结构:

[StructLayout(LayoutKind.Sequential)]  // default for structs
private struct Entry  //<Tkey, TValue>
{
    //  View code at *Reference Source
}

[Serializable, StructLayout(LayoutKind.Sequential)]
public struct Enumerator : 
    IEnumerator<KeyValuePair<TKey, TValue>>, IDisposable, 
    IDictionaryEnumerator, IEnumerator
{
    //  View code at *Reference Source
}

* 参考资料

“JonnyCantCode.com” 源代码中有 3 个获得 4 分 - 可以原谅,因为#4 可能不是问题。如果您发现自己正在装箱一个结构,请重新考虑您的体系结构。

让我们看看 Microsoft 为什么要使用这些结构:

  1. 每个EntryEnumerator结构都代表单个值。
  2. 速度
  3. Entry永远不会作为参数传递到 Dictionary 类之外。进一步的研究表明,为了满足 IEnumerable 的实现,Dictionary 使用了Enumerator结构,该结构在每次请求枚举器时都将其复制。
  4. 在 Dictionary 类的内部。 Enumerator是公共的,因为字典是可枚举的,并且必须对 IEnumerator 接口实现具有同等的可访问性 - 例如 IEnumerator getter。

更新 - 另外,请注意,当结构实现一个接口(如 Enumerator 一样)并将其强制转换为该实现的类型时,该结构将成为引用类型并移至堆中。内部的 Dictionary 类,枚举仍然一个值类型。但是,方法调用GetEnumerator() ,将立即返回引用类型的IEnumerator

我们在这里看不到的是保持结构不变或将实例大小保持为 16 字节或更小的任何尝试或要求证明:

  1. 上面的结构中的任何内容均未声明为readonly - 不可更改
  2. 这些结构的大小可能超过 16 个字节
  3. Entry的生存期不确定(从Add()Remove()Clear()或垃圾回收);

... ... 4. 两个结构都存储 TKey 和 TValue,我们都知道它们完全可以作为引用类型(添加了奖励信息)

尽管使用了哈希键,但字典很快,部分原因是实例化结构比引用类型更快。在这里,我有一个Dictionary<int, int> ,它存储着 300,000 个具有顺序递增键的随机整数。

容量:312874
MemSize:2660827 字节
完成大小调整:5ms
总填充时间:889ms

容量 :必须调整内部阵列大小之前可用的元素数。

MemSize :通过将字典序列化为 MemoryStream 并获取字节长度(对于我们的目的足够准确)来确定。

完成调整大小 :将内部数组的大小从 150862 个元素调整为 312874 个元素所需的时间。当您确定每个元素都是通过Array.CopyTo()顺序复制的时,还不算太破旧。

总填充时间 :由于日志记录和我添加到源中的OnResize事件而导致时间偏斜;但是,在操作过程中将大小调整为 15 倍时,填充 30 万个整数仍然令人印象深刻。出于好奇,如果我已经知道处理能力,那么总的工作时间将是多少? 13 毫秒

那么,现在,如果Entry是一堂课怎么办?这些时间或指标真的会有很大的不同吗?

容量:312874
MemSize:2660827 字节
完成大小调整:26ms
总填充时间:964ms

显然,最大的区别在于调整大小。如果使用 Capacity 初始化 Dictionary,有什么不同?不足以关注... 12ms

发生的是,因为Entry是结构,所以它不需要像引用类型一样的初始化。这既是价值型的美,也是价值型的祸根。为了使用Entry作为引用类型,我必须插入以下代码:

/*
 *  Added to satisfy initialization of entry elements --
 *  this is where the extra time is spent resizing the Entry array
 * **/
for (int i = 0 ; i < prime ; i++)
{
    destinationArray[i] = new Entry( );
}
/*  *********************************************** */

我必须将Entry每个数组元素初始化为引用类型的原因可以在MSDN:Structure Design 中找到 。简而言之:

不提供结构的默认构造函数。

如果结构定义了默认构造函数,则在创建该结构的数组时,公共语言运行库会在每个数组元素上自动执行默认构造函数。

某些编译器(例如 C#编译器)不允许结构具有默认构造函数。

这实际上很简单,我们将借鉴阿西莫夫的机器人三定律

  1. 该结构必须安全使用
  2. 该结构必须有效地执行其功能,除非这会违反规则 1
  3. 除非必须销毁结构才能满足规则 1,否则结构在使用过程中必须保持完整

... 我们从中得到什么 :总之,要对值类型的使用负责。它们是快速而有效的,但如果维护不当,则具有引起许多意外行为的能力(即无意复制)。

每当不需要多态性时,就需要值语义,并希望避免堆分配和相关的垃圾回收开销。但是,需要注意的是,传递结构(任意大)比使用类引用(通常是一个机器单词)要昂贵得多,因此在实践中类最终可能会更快。

我不同意原始帖子中给出的规则。这是我的规则:

1)将结构存储在数组中时,可以使用其性能。 (另请参见何时构造答案?

2)在将结构化数据往返于 C / C ++ 的代码中需要它们

3)除非需要它们,否则不要使用它们:

  • 它们在赋值和作为参数传递时的行为不同于 “正常对象”( 引用类型 ),这可能导致意外行为;如果查看代码的人不知道自己正在处理结构,则这特别危险。
  • 它们不能被继承。
  • 将结构作为参数传递比使用类昂贵。

当您想要值语义而不是引用语义时,请使用结构。

编辑

不知道为什么人们不赞成这一点,但这是有道理的,是在操作者澄清他的问题之前提出的,这是结构最根本的基本原因。

如果需要引用语义,则需要一个类而不是一个结构。

除了 “这是一个值” 答案之外,使用结构的一种特定情况是,当您知道有一组导致垃圾回收问题的数据,并且有很多对象时。例如,一个很大的 Person 实例列表 / 数组。这里的自然隐喻是一个类,但是如果您有大量的 Person 实例,它们最终可能会阻塞 GEN-2 并导致 GC 停顿。如果情况允许,这里一种可能的方法是使用 Person 结构的数组(而不是列表),即Person[] 。现在,不是在 GEN-2 中有数百万个对象,而是在 LOH 上有一个块(我假设这里没有字符串等 - 即没有任何引用的纯值)。这对 GC 的影响很小。

使用此数据很尴尬,因为该数据对于某个结构而言可能过大,并且您不想一直复制胖值。但是,直接在数组中访问它不会复制该结构 - 它是就位的(与确实复制的列表索引器相反)。这意味着需要使用大量索引:

int index = ...
int id = peopleArray[index].Id;

请注意,保持值本身不变是有帮助的。对于更复杂的逻辑,请使用带有 by-ref 参数的方法:

void Foo(ref Person person) {...}
...
Foo(ref peopleArray[index]);

同样,这是就位的 - 我们尚未复制该值。

在非常特定的情况下,此策略可能会非常成功。但是,这是一个相当高级的场景,只有在您知道自己在做什么和为什么的情况下才应尝试。这里的默认值是一个类。

根据C#语言规范

1.7 结构

与类一样,结构是可以包含数据成员和函数成员的数据结构,但是与类不同,结构是值类型,不需要堆分配。结构类型的变量直接存储结构的数据,而类类型的变量存储对动态分配对象的引用。结构类型不支持用户指定的继承,并且所有结构类型都隐式继承自类型对象。

结构对于具有值语义的小型数据结构特别有用。复数,坐标系中的点或字典中的键 - 值对都是结构的良好示例。对于小型数据结构,使用结构而不是类可以在应用程序执行的内存分配数量上产生很大差异。例如,以下程序创建并初始化一个 100 点的数组。将 Point 实现为一个类时,将实例化 101 个单独的对象 - 一个用于数组,一个用于 100 个元素。

class Point
{
   public int x, y;

   public Point(int x, int y) {
      this.x = x;
      this.y = y;
   }
}

class Test
{
   static void Main() {
      Point[] points = new Point[100];
      for (int i = 0; i < 100; i++) points[i] = new Point(i, i);
   }
}

另一种方法是将 Point 用作结构。

struct Point
{
   public int x, y;

   public Point(int x, int y) {
      this.x = x;
      this.y = y;
   }
}

现在,仅实例化一个对象(一个用于数组的对象),并且 Point 实例以内联方式存储在数组中。

使用 new 运算符调用结构构造函数,但这并不意味着正在分配内存。代替动态分配对象并返回对它的引用,结构构造函数仅返回结构值本身(通常在堆栈的临时位置),然后根据需要复制该值。

对于类,两个变量可以引用同一对象,因此对一个变量的操作可能会影响另一个变量引用的对象。使用结构时,变量每个都有其自己的数据副本,并且对一个变量的操作不可能影响另一个变量。例如,以下代码片段产生的输出取决于 Point 是类还是结构。

Point a = new Point(10, 10);
Point b = a;
a.x = 20;
Console.WriteLine(b.x);

如果 Point 是一个类,则输出为 20,因为 a 和 b 引用相同的对象。如果 Point 是一个结构,则输出为 10,因为 a 到 b 的赋值创建了该值的副本,并且此副本不受后续对 ax 赋值的影响

前面的示例突出了结构的两个局限性。首先,复制整个结构通常比复制对象引用的效率低,因此,与引用类型相比,结构的赋值和值参数传递可能更昂贵。其次,除了 ref 和 out 参数外,无法创建对结构的引用,这会在许多情况下排除它们的使用。

结构对于数据的原子表示很有用,其中所述数据可以通过代码多次复制。克隆对象通常比复制结构要昂贵,因为克隆涉及到分配内存,运行构造函数以及完成后回收 / 垃圾回收。

这是一个基本规则。

  • 如果所有成员字段都是值类型,则创建一个struct

  • 如果任何一个成员字段是引用类型,请创建一个class 。这是因为引用类型字段仍然需要堆分配。

精品

public struct MyPoint 
{
    public int X; // Value Type
    public int Y; // Value Type
}

public class MyPointWithName 
{
    public int X; // Value Type
    public int Y; // Value Type
    public string Name; // Reference Type
}

第一:互操作方案或需要指定内存布局的情况

第二:无论如何,数据的大小几乎都与参考指针相同。

在要使用StructLayoutAttribute显式指定内存布局的情况下,需要使用 “结构”(通常用于 PInvoke)。

编辑:评论指出,您可以将类或结构与 StructLayoutAttribute 一起使用,这确实是正确的。在实践中,通常会使用一个结构 - 它是在堆栈与堆上分配的,如果您只是将参数传递给非托管方法调用,这是有意义的。