[编辑:]“ref-reassign”是on the schedule for C# 7.3。我在下面讨论的“条件引用”解决方法是deployed in C# 7.2。
我也对此感到很沮丧,最近stumbled on a workable answer。
基本上,在 C# 7.2 中,您现在可以使用 ternary operator 初始化 ref locals,这是可以人为设计的。有点折磨人,进入了 ref-local reassignment 的模拟。当您在 C# 代码的词法范围内向下移动时,您通过多个变量向下“传递”ref local 赋值。
这种方法需要大量的非传统思维和大量的提前计划。对于某些情况或编码场景,可能无法预测运行时配置的范围,因此任何条件分配方案都可能适用。在这种情况下,你不走运。或者,切换到C++/CLI,它会公开managed tracking references。这里的张力在于,对于 C#,通过引入托管指针的常规使用(这些点将在下面进一步讨论)与克服重新分配问题所需的扭曲程度有关。
下面显示了我长期以来一直无法理解的语法。或者,查看我在顶部引用的link。
C# 7.2 ref-local 条件赋值通过三元运算符 ? :
ref int i_node = ref (f ? ref m_head : ref node.next);
此行来自提问者在此提出的ref local 困境的典型问题案例。它来自在遍历单链表时维护反向指针的代码。该任务在 C/C++ 中是微不足道的,因为它应该是(并且很受 CSE101 教师的喜爱,也许是因为这个特殊原因)——但使用托管指针 C# 完全令人痛苦。
这样的抱怨也是完全合理的,这要归功于微软自己的 C++/CLI 语言向我们展示了托管指针在 .NET 领域中的出色表现。相反,大多数 C# 开发人员似乎最终只是在数组中使用整数索引,或者当然是带有 unsafe C# 的完整本机指针。
关于链表遍历示例的一些简短的 cmets,以及为什么有人会对这些托管指针进行如此多的麻烦感兴趣。我们假设所有节点实际上都是数组中的结构(ValueType, in-situ),例如m_nodes = new Node[100];,因此每个next 指针都是一个整数(它在数组中的索引) .
struct Node
{
public int ix, next;
public char data;
public override String ToString() =>
String.Format("{0} next: {1,2} data: {2}", ix, next, data);
};
如此处所示,列表的 head 将是一个独立的整数,与记录分开存储。在下一个 sn-p 中,我使用新的 C#7 syntax 为 ValueTuple 这样做。显然,使用这些整数链接向前遍历是没有问题的——但 C# 传统上缺乏一种优雅的方式来维护指向您来自的节点的链接。这是一个问题,因为其中一个整数(第一个)是一种特殊情况,因为它没有嵌入到 Node 结构中。
static (int head, Node[] nodes) L =
(3,
new[]
{
new Node { ix = 0, next = -1, data = 'E' },
new Node { ix = 1, next = 4, data = 'B' },
new Node { ix = 2, next = 0, data = 'D' },
new Node { ix = 3, next = 1, data = 'A' },
new Node { ix = 4, next = 2, data = 'C' },
});
此外,在每个节点上大概有相当数量的处理工作要做,但你真的不想支付每个(可能很大)ValueType 的(双倍)性能成本,而不是它舒适的阵列家—然后必须在完成后将每一个图像重新映射! 毕竟,我们在这里使用值类型的原因肯定是为了最大限度地提高性能。正如我在discuss at length elsewhere on this site 中一样,结构在.NET 中非常有效,但前提是您绝不会意外地将它们从存储中“取出”。这很容易做到,它会立即破坏您的内存总线带宽。
不提升结构的简单方法只是重复数组索引,如下所示:
int ix = 1234;
arr[ix].a++;
arr[ix].b ^= arr[ix].c;
arr[ix].d /= (arr[lx].e + arr[ix].f);
在这里,每个ValueType 字段访问在每次访问时都被独立取消引用。尽管这种“优化”确实避免了上面提到的带宽损失,但一遍又一遍地重复相同的数组索引操作可能会带来一组完全不同的运行时损失。现在的(机会)成本是由于不必要地浪费了周期,.NET 重新计算可证明不变的物理偏移或对阵列执行冗余边界检查。
发布模式下的 JIT 优化可以通过识别和整合您提供的代码中的冗余来在一定程度上缓解这些问题,甚至可以显着缓解这些问题,但可能没有您想象或希望的那么多(或最终意识到您不想要):JIT 优化受到严格遵守 .NET Memory Model 的严格限制。[1] 这要求每当存储位置公开可见时,CPU 必须完全按照代码中编写的方式执行相关的提取序列。对于前面的例子,这意味着如果ix 在arr 上的操作之前以任何方式与其他线程共享,那么JIT 必须确保CPU 实际接触到ix 存储位置正好 6 次,不多也不少。
当然,JIT 无法通过重复源代码(例如前面的示例)解决其他明显的问题和widely-acknowledged problem。简而言之,它很丑陋,容易出错,而且更难阅读和维护。为了说明这一点,
☞ ...你有没有注意到我在前面的代码中故意放置的错误?
接下来显示的更简洁的代码版本不会使这样的错误“更容易发现”;相反,作为一个类,它完全排除了它们,因为现在根本不需要数组索引变量。变量ix 不需要在下面存在,因为1234 只使用一次。因此,我之前如此狡猾地引入的错误不能传播到这个例子,因为它没有表达方式,好处是不能存在的东西不能引入错误(相对于'what does not 存在...',这很可能是一个错误)
ref Node rec = ref arr[1234];
rec.a++;
rec.b ^= rec.c;
rec.d /= (rec.e + rec.f);
没有人会不同意这是一种改进。因此,理想情况下,我们希望使用托管指针直接读取和写入结构中的字段原位。一种方法是将所有密集处理代码编写为ValueType 本身中的实例成员函数和属性,尽管出于某种原因,似乎很多人不喜欢这种方法。无论如何,现在 C#7 ref locals...
✹ ✹
我现在意识到,在这里完全解释所需的编程类型可能过于复杂,无法用一个玩具示例来展示,因此超出了 StackOverflow 文章的范围。所以我要跳到前面,为了结束,我将介绍一些工作代码的一部分,我展示了模拟的托管指针重新分配。这是从.NET 4.7.1 reference source[直接链接] 中的HashSet<T> 的一个经过大量修改的快照中截取的,我将只显示我的版本而不做太多解释:
int v1 = m_freeList;
for (int w = 0; v1 != -1; w++)
{
ref int v2 = ref (w == 0 ? ref m_freeList : ref m_slots[v1].next);
ref Slot fs = ref m_slots[v2];
if (v2 >= i)
{
v2 = fs.next;
fs = default(Slot);
v1 = v2;
}
else
v1 = fs.next;
}
这只是工作代码中的任意示例片段,所以我不希望任何人关注它,但它的要点是,指定为 v1 和 v2 的“ref”变量相互交织在一起范围块和三元运算符用于协调它们如何向下流动。例如,循环变量w 的唯一目的是处理在链表遍历开始时为特殊情况激活的变量(前面讨论过)。
再次证明,这对现代 C# 的正常易用性和流动性来说是一个非常奇怪和折磨人的约束。耐心、决心,以及——正如我之前提到的——需要大量提前计划。
[1.]
如果您不熟悉所谓的.NET Memory Model,我强烈建议您看看。我相信 .NET 在这一领域的优势是它最引人注目的特性之一,它是一颗隐藏的宝石,也是一个(不那么)秘密的超级大国,它最致命的是让我们那些仍然坚持 1980 年代精神的那些一直尖锐的朋友感到尴尬裸机编码。注意一个史诗般的讽刺:对编译器优化的狂野或无限侵略施加严格限制可能最终使应用程序具有更好的性能,因为更强的约束向开发人员提供了可靠的保证。这些反过来又意味着更强大的编程抽象或建议高级设计范式,在这种情况下与并发系统相关。
例如,如果有人同意,在本地社区中,无锁编程已经处于边缘地位几十年来,也许应该归咎于优化编译器的不守规矩的暴徒?如果没有严格且定义明确的内存模型所提供的可靠确定性和一致性,这一专业领域的进展很容易被破坏,如前所述,这与不受约束的编译器优化有些不一致。所以在这里,约束意味着该领域最终可以创新和发展。这就是我在 .NET 中的经验,在 .NET 中,无锁编程已成为一种可行的、现实的——最终成为平凡的——基本的日常编程工具。