获取数组内结构体的引用

29
我想修改一个数组中的一个结构体字段,而不必设置整个结构体。在下面的示例中,我想设置数组中第543个元素的一个字段。我不想复制整个元素(因为复制 MassiveStruct 会影响性能)。
class P
{
    struct S
    {
      public int a;
      public MassiveStruct b;
    }

    void f(ref S s)
    {
      s.a = 3;
    }

    public static void Main()
    {
      S[] s = new S[1000];
      f(ref s[543]);  // Error: An object reference is required for the non-static field, method, or property
    }
}

有没有办法在C#中直接修改结构体数组的某个元素,而不必将整个结构体复制出来进行修改,再将修改后的副本放回数组中?

4个回答

79
[编辑 2017年:请参见本文末尾有关C#7的重要评论。]
经过多年的研究,我总结了几种技术和解决方案来应对这个问题。除了风格口味之外,结构体数组真的是在C#中唯一的内存中批量存储方法。如果你的应用程序确实在高吞吐量条件下处理数百万个中等大小的对象,则没有其他受控的替代方法。
我同意@kaalus的看法,即对象头和GC压力很快就会增加;然而,我的自然语言语法处理系统可以在不到一分钟的时间内操作8-10 GB(或更多)的结构分析,用于解析和/或生成漫长的自然语言句子。进入合唱团:“C#并不适合这样的问题……”,“切换到汇编语言……”,“上线一个FPGA……” 等等。

好的,我们来做一些测试。首先,完全理解值类型 (struct)管理问题和classstruct之间的权衡是至关重要的。当然还有装箱、固定缓冲区、GCHandleIntPtr等,但在我看来最重要的是明智地使用托管指针(也称为“内部指针”)。

你对这些主题的掌握还将包括以下知识:如果你在struct中包含一个或多个引用托管类型的引用(而不仅仅是可平铺原语),那么使用unsafe指针访问struct的选项会大大减少。但是对于下面要提到的托管指针方法来说,这不是问题。所以通常情况下,包括对象引用是可以的,并不会在这方面改变太多。

如果您确实需要保留unsafe访问,可以使用“正常”模式下的GCHandle来无限期地存储对象引用。幸运的是,将GCHandle放入结构中不会触发不安全访问禁令。(请注意,GCHandle本身是值类型,甚至可以定义和使用)

var gch = GCHandle.Alloc("spookee",GCHandleType.Normal);
GCHandle* p = &gch;
String s = (String)p->Target;

...等等。作为一个值类型,GCHandle 本身 直接映射到您的结构体中,但显然其引用的GC实例不是。它们在堆中,不包含在您数组的物理布局中。请注意,GCHandle不必处于“固定”模式。最后,在处理GCHandle时要注意其复制语义,因为如果您不最终释放每个分配的GCHandle,则会出现内存泄漏。

@Ani提醒我们,一些人认为可变的struct实例很“邪恶”,但问题实际上在于它们很容易出现意外情况。确实,OP的示例...

s[543].a = 3;

这段内容展示了我们试图实现的目标:在原地访问数据记录。请注意:class类型的引用数组语法与此类似,但本文仅讨论用户定义的值类型的非jagged数组。对于我的程序而言,如果我遇到一个超大的可平面化结构体完全被镜像出其数组存储行(意外地)的情况,我通常会认为这是一个严重的错误:

rec no_no = s[543];   // 不要这样做
no_no.a = 3           // 

至于你的struct有多大(宽),这并不重要,因为你将小心翼翼地永远不会让struct执行前面示例中展示的操作,即从其嵌入式数组中完全移出。实际上,这指向了本文的一个基本前提:

规则:
对于结构体数组,总是在原地访问单个字段;永远不要在C#中“提到”整个结构体实例。

不幸的是,C#语言没有系统地标记或禁止违反此规则的代码的方法,因此成功取决于仔细的编程纪律。

由于我们的“巨型结构”从未被映射出其数组,因此它们实际上只是内存上的模板。换句话说,正确的思考方式是将struct视为覆盖数组元素。我们总是将每个元素视为一个空的“内存模板”,而不是可传输或可移植的封装器或数据容器。对于数组限定的“巨型”值类型,我们从不想调用“结构体”的最基本特性,即按值传递。

例如:

public struct rec
{
    public int a, b, c, d, e, f;
}

在这里,我们为每个“记录”叠加了6个int,总共占用24字节。您需要考虑并注意打包选项以获得对齐友好的大小。但是过多的填充可能会削减您的内存预算:因为更重要的考虑因素是非LOH对象的85,000字节限制。确保您的记录大小乘以预期行数不超过此限制。
因此,对于此处给出的示例,最好建议您将rec数组保持在每个数组不超过3,000行。希望您的应用程序可以围绕这个甜点设计。当您记住另一种选择——每行都是一个单独的垃圾回收对象时,这并不是很限制。您已经将对象扩散降低了三个数量级,这对于一天的工作来说非常不错。因此,在这里,.NET环境强烈引导我们使用相当特定的约束来设计应用程序的内存,似乎如果您将应用程序的内存设计目标定向到30-70 KB范围内的单块分配,那么您确实可以大量地使用它们,实际上,您将受到更棘手的性能瓶颈控制(即,硬件内存总线的带宽)。
现在您有一个单一的.NET引用类型(数组),其中包含3,000个6元组,以物理连续的表格存储方式。首先,我们必须非常小心,永远不要“拾取”其中一个结构体。正如Jon Skeet上面所指出的,“大型结构体的性能通常比类差”,这是绝对正确的。没有比开始随意抛掷丰满值类型更好的方法来瘫痪您的内存总线了。

因此,让我们利用结构体数组中很少提到的一个方面:整个数组的所有行的所有对象(及其对象或结构体的字段)始终初始化为它们的默认值。您可以从任何行或列(字段)的任何位置开始逐个插入值。您可以将某些字段保留在其默认值,或替换相邻字段而不干扰中间的字段。在使用之前,不需要手动初始化堆栈驻留(本地变量)结构体,这令人恼火的手动初始化已经消失了。

有时候很难维护逐个字段的方法,因为.NET总是试图让我们轰入一个完整的new'd-up结构体,但对我来说,这种所谓的“初始化”只是违反了我们的禁忌(不要从数组中拔出整个结构体),换了一种方式而已。

现在我们来到问题的核心。显然,在原地访问表格数据可以最小化繁琐的数据移动工作。但通常这是一个不方便的麻烦。由于.NET中的边界检查,数组访问可能很慢。那么,如何维护一个指向数组内部的“工作”指针,以避免系统不断重新计算索引偏移量呢?
评估
让我们评估五种不同方法对值类型数组存储行中单个字段进行操作的性能。下面的测试旨在衡量密集访问位于某个数组索引处的结构体的数据字段的效率,即在原地——也就是“它们所在的地方”,而不是提取或重写整个结构体(数组元素)。比较了五种不同的访问方法,其他所有因素都保持不变。
这五种方法如下:
  1. 普通方式,通过方括号和字段指定符号直接访问数组。需要注意的是,在.NET中,数组是公共类型系统的一种特殊且独特的原语。如上所述,这种语法不能用于使用其他集合类型(例如 List<T> where T: struct)直接修改索引结构元素的字段。
  2. 使用未记录的C#语言关键字__makeref
  3. 通过使用ref关键字的委托来使用托管指针
  4. "不安全"指针
  5. 与#3相同,但使用C#函数而非委托。

在给出C#测试结果之前,这里是测试工具实现。这些测试是在.NET 4.5上运行的,是一个AnyCPU版本的发布版本,运行在x64平台上,使用Workstation gc。(请注意,由于测试不关心分配和释放数组本身的效率,因此上面提到的LOH问题并不适用。)

const int num_test = 100000;
static rec[] s1, s2, s3, s4, s5;
static long t_n, t_r, t_m, t_u, t_f;
static Stopwatch sw = Stopwatch.StartNew();
static Random rnd = new Random();

static void test2()
{
    s1 = new rec[num_test];
    s2 = new rec[num_test];
    s3 = new rec[num_test];
    s4 = new rec[num_test];
    s5 = new rec[num_test];

    for (int x, i = 0; i < 5000000; i++)
    {
        x = rnd.Next(num_test);
        test_m(x); test_n(x); test_r(x); test_u(x); test_f(x);
        x = rnd.Next(num_test);
        test_n(x); test_r(x); test_u(x); test_f(x); test_m(x);
        x = rnd.Next(num_test);
        test_r(x); test_u(x); test_f(x); test_m(x); test_n(x);
        x = rnd.Next(num_test);
        test_u(x); test_f(x); test_m(x); test_n(x); test_r(x);
        x = rnd.Next(num_test);
        test_f(x); test_m(x); test_n(x); test_r(x); test_u(x);
        x = rnd.Next(num_test);
    }
    Debug.Print("Normal (subscript+field):          {0,18}", t_n);
    Debug.Print("Typed-reference:                   {0,18}", t_r);
    Debug.Print("C# Managed pointer: (ref delegate) {0,18}", t_m);
    Debug.Print("C# Unsafe pointer:                 {0,18}", t_u);
    Debug.Print("C# Managed pointer: (ref func):    {0,18}", t_f);
}

由于实现每个特定方法的测试的代码片段有点长,我将先给出结果。时间是“ticks;”,较低的时间意味着更好。
Normal (subscript+field):             20,804,691
Typed-reference:                      30,920,655
Managed pointer: (ref delegate)       18,777,666   // <- a close 2nd
Unsafe pointer:                       22,395,806
Managed pointer: (ref func):          18,767,179   // <- winner

我很惊讶这些结果是如此明确的。`TypedReferences` 是最慢的,可能是因为它们随着指针一起携带类型信息。考虑到那个费力的“Normal”版本的 IL 代码的重量,它表现得出奇地好。模式转换似乎会对不安全代码造成伤害,以至于您真的必须证明、计划和测量每个要部署的位置。
但是,利用函数参数传递中的 `ref` 关键字指向数组内部部分以达到消除“每个字段访问”的数组索引计算目的可以实现最快的时间。
也许我的测试设计有利于这一个,但测试场景代表了我的应用程序中的经验使用模式。令我惊讶的是,在保持托管模式的优势的同时——同时拥有指针——并不会因为需要调用函数或通过委托调用而被取消。
胜者:
最快的一个:(也许是最简单的?)
static void f(ref rec e)
{
    e.a = 4;
    e.e = e.a;
    e.b = e.d;
    e.f = e.d;
    e.b = e.e;
    e.a = e.c;
    e.b = 5;
    e.d = e.f;
    e.c = e.b;
    e.e = e.a;
    e.b = e.d;
    e.f = e.d;
    e.c = 6;
    e.b = e.e;
    e.a = e.c;
    e.d = e.f;
    e.c = e.b;
    e.e = e.a;
    e.d = 7;
    e.b = e.d;
    e.f = e.d;
    e.b = e.e;
    e.a = e.c;
    e.d = e.f;
    e.e = 8;
    e.c = e.b;
    e.e = e.a;
    e.b = e.d;
    e.f = e.d;
    e.b = e.e;
    e.f = 9;
    e.a = e.c;
    e.d = e.f;
    e.c = e.b;
    e.e = e.a;
    e.b = e.d;
    e.a = 10;
    e.f = e.d;
    e.b = e.e;
    e.a = e.c;
    e.d = e.f;
    e.c = e.b;
}
static void test_f(int ix)
{
    long q = sw.ElapsedTicks;
    f(ref s5[ix]);
    t_f += sw.ElapsedTicks - q;
}

但它的缺点是,你无法在程序中将相关逻辑保持在一起:函数的实现分散在两个C#函数ftest_f之间。
我们可以通过仅牺牲一点性能来解决这个特定问题。下一个示例与前面的示例基本相同,但将其中一个函数嵌入另一个函数作为lambda函数...
第二个示例非常接近。
在前面的示例中用静态函数替换内联委托需要使用ref参数,这反过来又排除了使用Func<T> lambda语法;而必须使用旧式.NET中的显式委托。
通过添加这个全局声明一次:
delegate void b(ref rec ee);

...我们可以在整个程序中使用它,直接ref到数组rec []的元素中,在线访问它们:

static void test_m(int ix)
{
    long q = sw.ElapsedTicks;
    /// the element to manipulate "e", is selected at the bottom of this lambda block
    ((b)((ref rec e) =>
    {
        e.a = 4;
        e.e = e.a;
        e.b = e.d;
        e.f = e.d;
        e.b = e.e;
        e.a = e.c;
        e.b = 5;
        e.d = e.f;
        e.c = e.b;
        e.e = e.a;
        e.b = e.d;
        e.f = e.d;
        e.c = 6;
        e.b = e.e;
        e.a = e.c;
        e.d = e.f;
        e.c = e.b;
        e.e = e.a;
        e.d = 7;
        e.b = e.d;
        e.f = e.d;
        e.b = e.e;
        e.a = e.c;
        e.d = e.f;
        e.e = 8;
        e.c = e.b;
        e.e = e.a;
        e.b = e.d;
        e.f = e.d;
        e.b = e.e;
        e.f = 9;
        e.a = e.c;
        e.d = e.f;
        e.c = e.b;
        e.e = e.a;
        e.b = e.d;
        e.a = 10;
        e.f = e.d;
        e.b = e.e;
        e.a = e.c;
        e.d = e.f;
        e.c = e.b;
    }))(ref s3[ix]);
    t_m += sw.ElapsedTicks - q;
}

此外,尽管每次调用似乎会实例化一个新的lambda函数,但如果您小心谨慎,就不会发生这种情况:在使用此方法时,请确保不要“关闭”任何局部变量(即从其主体内部引用在lambda函数之外的变量)或执行任何其他将阻止委托实例成为静态的操作。如果一个局部变量恰好落入您的lambda中,并且lambda因此被提升为实例/类,则在尝试创建五百万个委托时,“可能”会注意到差异。
只要让lambda函数远离这些副作用,就不会有多个实例;这里发生的是,只要C#确定lambda没有非显式依赖项,它就会惰性地创建(并缓存)一个静态单例。令人遗憾的是,这种如此明显的性能变化被隐藏在我们看不见的优化中。总的来说,我喜欢这种方法。它快速而且简洁 - 除了奇怪的括号外,这里没有任何可以省略的内容。

其他测试

为了完整起见,这里是剩下的测试:普通的括号加点号;TypedReference;和不安全指针。
static void test_n(int ix)
{
    long q = sw.ElapsedTicks;
    s1[ix].a = 4;
    s1[ix].e = s1[ix].a;
    s1[ix].b = s1[ix].d;
    s1[ix].f = s1[ix].d;
    s1[ix].b = s1[ix].e;
    s1[ix].a = s1[ix].c;
    s1[ix].b = 5;
    s1[ix].d = s1[ix].f;
    s1[ix].c = s1[ix].b;
    s1[ix].e = s1[ix].a;
    s1[ix].b = s1[ix].d;
    s1[ix].f = s1[ix].d;
    s1[ix].c = 6;
    s1[ix].b = s1[ix].e;
    s1[ix].a = s1[ix].c;
    s1[ix].d = s1[ix].f;
    s1[ix].c = s1[ix].b;
    s1[ix].e = s1[ix].a;
    s1[ix].d = 7;
    s1[ix].b = s1[ix].d;
    s1[ix].f = s1[ix].d;
    s1[ix].b = s1[ix].e;
    s1[ix].a = s1[ix].c;
    s1[ix].d = s1[ix].f;
    s1[ix].e = 8;
    s1[ix].c = s1[ix].b;
    s1[ix].e = s1[ix].a;
    s1[ix].b = s1[ix].d;
    s1[ix].f = s1[ix].d;
    s1[ix].b = s1[ix].e;
    s1[ix].f = 9;
    s1[ix].a = s1[ix].c;
    s1[ix].d = s1[ix].f;
    s1[ix].c = s1[ix].b;
    s1[ix].e = s1[ix].a;
    s1[ix].b = s1[ix].d;
    s1[ix].a = 10;
    s1[ix].f = s1[ix].d;
    s1[ix].b = s1[ix].e;
    s1[ix].a = s1[ix].c;
    s1[ix].d = s1[ix].f;
    s1[ix].c = s1[ix].b;
    t_n += sw.ElapsedTicks - q;
}


static void test_r(int ix)
{
    long q = sw.ElapsedTicks;
    var tr = __makeref(s2[ix]);
    __refvalue(tr, rec).a = 4;
    __refvalue(tr, rec).e = __refvalue( tr, rec).a;
    __refvalue(tr, rec).b = __refvalue( tr, rec).d;
    __refvalue(tr, rec).f = __refvalue( tr, rec).d;
    __refvalue(tr, rec).b = __refvalue( tr, rec).e;
    __refvalue(tr, rec).a = __refvalue( tr, rec).c;
    __refvalue(tr, rec).b = 5;
    __refvalue(tr, rec).d = __refvalue( tr, rec).f;
    __refvalue(tr, rec).c = __refvalue( tr, rec).b;
    __refvalue(tr, rec).e = __refvalue( tr, rec).a;
    __refvalue(tr, rec).b = __refvalue( tr, rec).d;
    __refvalue(tr, rec).f = __refvalue( tr, rec).d;
    __refvalue(tr, rec).c = 6;
    __refvalue(tr, rec).b = __refvalue( tr, rec).e;
    __refvalue(tr, rec).a = __refvalue( tr, rec).c;
    __refvalue(tr, rec).d = __refvalue( tr, rec).f;
    __refvalue(tr, rec).c = __refvalue( tr, rec).b;
    __refvalue(tr, rec).e = __refvalue( tr, rec).a;
    __refvalue(tr, rec).d = 7;
    __refvalue(tr, rec).b = __refvalue( tr, rec).d;
    __refvalue(tr, rec).f = __refvalue( tr, rec).d;
    __refvalue(tr, rec).b = __refvalue( tr, rec).e;
    __refvalue(tr, rec).a = __refvalue( tr, rec).c;
    __refvalue(tr, rec).d = __refvalue( tr, rec).f;
    __refvalue(tr, rec).e = 8;
    __refvalue(tr, rec).c = __refvalue( tr, rec).b;
    __refvalue(tr, rec).e = __refvalue( tr, rec).a;
    __refvalue(tr, rec).b = __refvalue( tr, rec).d;
    __refvalue(tr, rec).f = __refvalue( tr, rec).d;
    __refvalue(tr, rec).b = __refvalue( tr, rec).e;
    __refvalue(tr, rec).f = 9;
    __refvalue(tr, rec).a = __refvalue( tr, rec).c;
    __refvalue(tr, rec).d = __refvalue( tr, rec).f;
    __refvalue(tr, rec).c = __refvalue( tr, rec).b;
    __refvalue(tr, rec).e = __refvalue( tr, rec).a;
    __refvalue(tr, rec).b = __refvalue( tr, rec).d;
    __refvalue(tr, rec).a = 10;
    __refvalue(tr, rec).f = __refvalue( tr, rec).d;
    __refvalue(tr, rec).b = __refvalue( tr, rec).e;
    __refvalue(tr, rec).a = __refvalue( tr, rec).c;
    __refvalue(tr, rec).d = __refvalue( tr, rec).f;
    __refvalue(tr, rec).c = __refvalue( tr, rec).b;
    t_r += sw.ElapsedTicks - q;
}

static void test_u(int ix)
{
    long q = sw.ElapsedTicks;

    fixed (rec* p = &s4[ix])
    {
        p->a = 4;
        p->e = p->a;
        p->b = p->d;
        p->f = p->d;
        p->b = p->e;
        p->a = p->c;
        p->b = 5;
        p->d = p->f;
        p->c = p->b;
        p->e = p->a;
        p->b = p->d;
        p->f = p->d;
        p->c = 6;
        p->b = p->e;
        p->a = p->c;
        p->d = p->f;
        p->c = p->b;
        p->e = p->a;
        p->d = 7;
        p->b = p->d;
        p->f = p->d;
        p->b = p->e;
        p->a = p->c;
        p->d = p->f;
        p->e = 8;
        p->c = p->b;
        p->e = p->a;
        p->b = p->d;
        p->f = p->d;
        p->b = p->e;
        p->f = 9;
        p->a = p->c;
        p->d = p->f;
        p->c = p->b;
        p->e = p->a;
        p->b = p->d;
        p->a = 10;
        p->f = p->d;
        p->b = p->e;
        p->a = p->c;
        p->d = p->f;
        p->c = p->b;
    }
    t_u += sw.ElapsedTicks - q;
}

概述

对于大规模 C# 应用程序中的内存密集型工作,使用 托管指针 直接访问 值类型数组元素 的字段是可行的。

如果你真的很在意性能,这可能足以成为你使用 C++/CLI(或者同样适用于 CIL)而不是 C# 来处理应用程序相关部分的原因之一,因为这些语言允许你在函数体内直接声明托管指针。

C# 中,创建托管指针的唯一方法是声明一个带有 refout 参数的函数,然后调用方将观察到托管指针。因此,在 C# 中获得性能优势,必须使用上述两种方法中的一种。 [请参见下面的 C#7]

遗憾的是,这些部署使用了将一个函数分成多个部分的拙劣方法,只为了访问数组元素。虽然不如等效的 C++/CLI 代码优雅,但测试表明,在 C# 中,对于高吞吐量应用程序,即使与朴素的值类型数组访问相比,我们仍然获得了巨大的性能优势。


[编辑于2017年: 尽管此文章中的一般忠告可能会赋予一定程度的先见之明,但是在同一时间发布的Visual Studio 2017中的C# 7版本使得上述特定方法完全过时。简而言之,语言中的新ref locals功能允许您将自己的托管指针声明为局部变量,并将其用于合并单个数组解引用操作。因此,以上述测试结构为例...

public struct rec { public int a, b, c, d, e, f; }
static rec[] s7 = new rec[100000];

以下是如何编写与上述相同的测试函数:

static void test_7(int ix)
{
    ref rec e = ref s7[ix];         // <---  C#7 ref local
    e.a = 4;  e.e = e.a; e.b = e.d; e.f = e.d; e.b = e.e; e.a = e.c;
    e.b = 5;  e.d = e.f; e.c = e.b; e.e = e.a; e.b = e.d; e.f = e.d;
    e.c = 6;  e.b = e.e; e.a = e.c; e.d = e.f; e.c = e.b; e.e = e.a;
    e.d = 7;  e.b = e.d; e.f = e.d; e.b = e.e; e.a = e.c; e.d = e.f;
    e.e = 8;  e.c = e.b; e.e = e.a; e.b = e.d; e.f = e.d; e.b = e.e;
    e.f = 9;  e.a = e.c; e.d = e.f; e.c = e.b; e.e = e.a; e.b = e.d;
    e.a = 10; e.f = e.d; e.b = e.e; e.a = e.c; e.d = e.f; e.c = e.b;
}

注意,这完全消除了我上面讨论的那些丑陋解决方案的需求。使用受控指针更为简洁,避免了“获胜者”中不必要的函数调用,这是我审查过的最佳性能方法之一。因此,使用新功能的性能只会比上述方法中的获胜者更好。
具有讽刺意味的是,C# 7 还添加了本地函数,这个特性直接解决了我对前两种 hack 的封装不良的抱怨。令人高兴的是,为了获取对受控指针的访问而大量繁殖专用函数的整个企业现在已经完全无用了。

谢谢。关于CLI值类型数组的更详细处理,请参阅http://www.glennslayden.com/pubs/slayden-2012-array-TFS.pdf第38页开始的部分。 - Glenn Slayden
虽然数组是唯一可以公开“外部”byref的类型,但用户集合类型可以通过公开接受访问其内容所需数据、(可能是静态的)委托、一个或多个byref参数的方法来允许byref访问其内容。因此,例如,可以说myCollection.OperateOnItem(index, (ref Point pt, ref int x) => pt.x = x, ref x);语法最终变得相当丑陋,但不需要对象分配。 - supercat
1
如果被调用的方法需要操作调用者的堆栈帧内的变量,则这些变量可以提升为结构体。与闭包不同,闭包会改变堆栈变量的语义并将其替换为存储在堆中的对象,将变量提升到公开字段结构中不会对性能或行为产生显着影响。在大多数情况下,OperateOnItem 上的单个 ref 参数就足够了。但是,我不确定该方法的泛型参数的确切性能影响,因为泛型有时会有效地分派,有时则不会... - supercat
我不知道影响这个的因素是什么。然而,有时候单一的统一数组可能不是一个合适的存储表示方式,人们可能希望隐藏实际存储的细节,但仍需要能够在原地操作值类型;在这种情况下,“OperateOnItem”模式可能会有所帮助。 - supercat
1
在一个批量的结构数组中,总是使用“ref local”(托管指针)直接访问内部字段。 - Glenn Slayden
显示剩余3条评论

12

唯一的问题是,您正在尝试从静态方法中调用实例方法,而没有P的实例。

f变为静态方法(或创建一个P的实例来调用它),那么就没问题了。这完全取决于如何阅读编译器错误 :)

话虽如此,我强烈建议您:

  • 如果可能,避免创建庞大的结构体
  • 如果可能,避免创建可变的结构体
  • 避免公共字段

关于那个建议——我同意。但在我的情况下,所涉及的结构体是类的私有部分,并且它是一个具有紧密循环的高性能算法的一部分,因此我认为我可以原谅这一点。 - kaalus
@kaalus:庞大的结构体往往比类执行效率更低。这取决于你对它的使用方式 - 但我同意,如果问题被限制在非常小的范围内,那么至少会好一些 :) - Jon Skeet
@JonSkeet:大规模所谓的“不可变”结构体在各个地方都需要进行冗余复制,并且性能较差。开放字段的结构体,只有在必要时才会通过引用进行传递,并且只有在需要对其值进行快照时才会进行复制,性能非常出色;在许多情况下,它们越大,与不可变类类型相比,性能优势越大。 - supercat
@supercat:就像我已经说过很多次的那样 - 我认为我们必须就可变结构体的优点达成一致意见。 - Jon Skeet

2

虽然Jon Skeet在解释为什么你的程序不能编译方面是正确的,但你可以这样做:

s[543].a = 3;

...并且它将直接在数组中的结构上运行,而不是在副本上运行。

请注意,这个想法仅适用于数组,其他集合(如列表)将从索引器-获取器中返回一个副本(如果您尝试在结果值上尝试类似的操作,将会得到编译器错误)。

另外,可变结构被认为是邪恶的。你有强烈的理由不想把S变成一个类吗?


10
我不想将它作为类的原因是我将在我的数组中拥有数百万个这样的对象,而我不想使垃圾回收器不堪重负或额外支付对象头的内存成本。 - kaalus

0

您可以尝试使用一个转发空结构, 它不持有实际数据,仅保存到数据提供者对象的索引。这样,您就可以存储大量数据,而不会使对象图形复杂化。 只要不尝试将其编组到非托管代码中,我非常确定在您的情况下将巨大的结构替换为转发空结构应该相当容易。

看一下这个结构。它可以包含任意多量的数据。诀窍在于,您将实际数据存储在另一个对象中。这样,您就获得了引用语义和结构体的优势,后者消耗比类对象更少的内存,并且具有更快的GC周期(如果您有数百万个实例)。

    [StructLayout(LayoutKind.Sequential, Pack=1)]
    public struct ForwardingEmptyValueStruct
    {
        int _Row;
        byte _ProviderIdx;


        public ForwardingEmptyValueStruct(byte providerIdx, int row)
        {
            _ProviderIdx = providerIdx;
            _Row = row;
        }

        public double V1
        {
            get { return DataProvider._DataProviders[_ProviderIdx].Value1[_Row];  }
        }

        public int V2
        {
            get { return DataProvider._DataProviders[_ProviderIdx].Value2[_Row];  }
        }
    }

值得注意的是,手动分配的数组槽通常是一种非托管资源,即使包含它们的数组是托管资源类型。因此,代码必须小心释放不再需要的任何数组槽,因为不再使用的槽否则将构成内存泄漏。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接