为什么这个结构体是可变的?什么情况下可变结构体是可以接受的?

35

Eric Lippert告诉我应该"尽量使值类型不可变",因此我认为我应该尽量使值类型不可变。

但是,在System.Web程序集中,我刚刚发现了这个内部可变结构System.Web.Util.SimpleBitVector32,这让我觉得一定有一个使用可变结构的好原因。我猜他们之所以这样做是因为在测试中表现更好,并将其保持为内部以防止滥用。然而,这只是猜测。

我已经复制并粘贴了这个结构的源代码。什么能够证明使用可变结构的设计决策是正确的?通常情况下,采用这种方法可以获得什么样的好处,什么时候这些好处足以证明潜在的不利影响合理呢?

[Serializable, StructLayout(LayoutKind.Sequential)]
internal struct SimpleBitVector32
{
    private int data;
    internal SimpleBitVector32(int data)
    {
        this.data = data;
    }

    internal int IntegerValue
    {
        get { return this.data; }
        set { this.data = value; }
    }

    internal bool this[int bit]
    {
        get { 
            return ((this.data & bit) == bit); 
        }
        set {
            int data = this.data;
            if (value) this.data = data | bit;
            else this.data = data & ~bit;
        }
    }

    internal int this[int mask, int offset]
    {
        get { return ((this.data & mask) >> offset); }
        set { this.data = (this.data & ~mask) | (value << offset); }
    }

    internal void Set(int bit)
    {
        this.data |= bit;
    }

    internal void Clear(int bit)
    {
        this.data &= ~bit;
    }
}

5
看看Eric在第二页对此发表的评论:很不幸没有直接链接,搜索“Eric Lippert 16 May 2008 11:16 AM”。 - BlackICE
@david,眼力真好 - 那绝对很有帮助。我想知道他对这个特定用途会有什么看法。 - smartcaveman
@smartcaveman:埃里克所说的与我的直觉相符,即性能确实在决定结构体是否可变方面发挥了作用。 - IAbstract
@IAbstract,看起来是这样。但我想更了解使用结构体的相对性能显著到足以证明其可变性的具体点,以及在什么情况下公共可变结构体会被证明是合理的。 - smartcaveman
请记住,仅仅因为代码在.NET框架中并不意味着它是最佳实践。一个很好的例子是mscorlib v2.0中的System.LOGIC类。它完全重新实现了布尔相等性。 - Kendall Frey
5个回答

19
考虑到有效负载是32位整数,我认为这可以轻松地编写为不可变结构体,而且可能对性能没有影响。无论您调用更改32位字段值的mutator方法还是替换为新的32位结构体,您仍然会执行完全相同的内存操作。
可能有人想要类似数组的东西(实际上只是32位整数中的位),因此他们决定使用索引器语法,而不是返回一个新结构体的不太明显的.WithTheseBitsChanged()方法。由于它不会直接被MS网站团队之外的任何人使用,而且在MS网站团队内部使用的人也很少,所以我想他们在设计决策方面有更多的自由度,而不是公共API的构建者。
因此,不,大概不是为了性能而那么做 - 这可能只是某个程序员在编码风格上的个人喜好,并且从来没有任何强制性的理由去更改它。
如果您正在寻找设计准则,则不要花太多时间查看未经过公共消费精炼的代码。

如果我必须猜的话,+1 数组行为可能是正确答案。 - BrokenGlass
1
总的来说,我同意关于内部实现的观点...但是这确实被纳入了System.Web中,并且在该程序集的最常见类中广泛使用...这一点必须有所考虑。 - smartcaveman
@brokenglass,它通常用于检查和设置标志。 - smartcaveman
@smartcaveman:性能只是解释为什么它是一个结构体,而不是我发布的为什么它是可变的原因——在这方面,这篇文章更好。 - BrokenGlass
已经有很多困惑的.NET程序员在SO上提出了问题,他们想知道为什么String.Replace()或DateTime.AddDays()不起作用。偶尔的不可变性并不是解决方案。 - Hans Passant
1
如所写,它似乎没有任何不可变结构体不能做的事情。然而,假设使用Interlocked.CompareExchange进行设置/重置操作编写它。这将保持完美的存储效率,同时允许多个线程在结构中使用不同的位而不会干扰。尝试使用不可变值类型将破坏线程安全性。 - supercat

15
实际上,如果您搜索包含位向量(BitVector)的所有类在 .NET 框架中,您会找到一堆这些东西 :-)
  • System.Collections.Specialized.BitVector32 (唯一的公共类...)
  • System.Web.Util.SafeBitVector32 (线程安全)
  • System.Web.Util.SimpleBitVector32
  • System.Runtime.Caching.SafeBitVector32 (线程安全)
  • System.Configuration.SafeBitVector32 (线程安全)
  • System.Configuration.SimpleBitVector32

如果您在这里查看 SSCLI(Microsoft 共享源代码 CLI,又称 ROTOR)源代码的 System.Configuration.SimpleBitVector32 ,您会找到这个注释:

//
// This is a cut down copy of System.Collections.Specialized.BitVector32. The
// reason this is here is because it is used rather intensively by Control and
// WebControl. As a result, being able to inline this operations results in a
// measurable performance gain, at the expense of some maintainability.
//
[Serializable()]
internal struct SimpleBitVector32

我相信这就是全部内容了。我认为System.Web.Util更为详细,但基于同样的原则构建。


15

SimpleBitVector32是可变的,我猜想出于与BitVector32相同的原因。在我看来,不可变性只是一个准则;但是,应该有一个非常好的理由才能这样做。

还要考虑到Dictionary<TKey, TValue>——我在这里提供了一些扩展细节。字典的Entry结构是可变的–您可以随时更改TValue。但是,Entry在逻辑上代表一个

可变性必须是合理的。我同意@JoeWhite的观点:有人希望得到类似于数组的东西(实际上只是32位整数中的位); 同样,两个BitVector结构 本来很容易是……不可变的

但是,作为一个概括的说法,我不同意这可能只是某个程序员个人编码风格的偏好,而更倾向于从来没有[也没有] 任何强制性理由来更改它。只需了解使用可变结构的责任。

编辑
为记录,我非常同意你应该总是尝试使结构体不可变。如果发现要求成员可变性,请重新审视设计决策并获得同事的参与。

更新
一开始,我对于考虑可变值类型 v. 不可变性能表现的评估并不自信。然而,正如@David指出的那样,Eric Lippert写道

有时候,您需要从系统中挤出最后一点性能。在这种情况下,有时您必须在干净、纯净、健壮、可理解、可预测、可修改的代码和非上述类型但极快的代码之间做出权衡。

我加粗了 pure,因为一个可变结构体并不符合纯净的理想,即一个结构体应该是不可变的。编写可变结构体会带来副作用:可理解性和可预测性会受到损害,正如 Eric 所解释的那样:

可变值类型...... 表现出许多人深感反直觉的行为,从而容易编写错误的代码(或者是通过意外轻松地将正确代码转化为错误代码)。但是,它们确实非常快。

Eirc 的观点是,作为设计师和/或开发人员,您需要做出有意识和知情的决策。您如何获得信息?Eric 也解释了其中的细节:

我会考虑编写两个基准测试解决方案——一个使用可变结构体,一个使用不可变结构体——并运行一些真实的用户场景基准测试。但是问题在于:不要选择更快的那个。相反,在运行基准测试之前,决定无法接受的缓慢有多慢。

我们知道修改值类型创建新值类型要快;但是考虑到正确性

如果两种解决方案都可以接受,请选择干净、正确且足够快的那一个。

关键在于速度足够以抵消选择可变而不是不可变所带来的副作用。只有您自己能够确定这一点。


这很公平。当然,每个使用您代码的人也需要理解这种责任,这可能是为什么Entry和SimpleBitVector32是内部的原因 - 这样只有微软的一小部分人真正需要了解实现方式。了解您的受众:如果您唯一的受众是您合作的人,而且他们都很聪明,那么您就有更多自由去做一些只有聪明人才能理解的事情。 - Joe White
我完全同意。因此,我不会将BitVector32暴露给公共可变性 - 例如,我会控制使用。 - IAbstract
+1,更新肯定增加了答案的价值。然而,我仍然认为“只有你能确定”有点逃避责任。我们必须有最佳实践或标准可供参考。 - smartcaveman
@smartcaveman: 有时候这就是最终答案了。一旦你掌握了事实,你仍然需要做出设计决策。最佳实践是不可变值类型,除非在罕见情况下性能成为首要因素。 :) - IAbstract
解决办法是教导人们 .net 不是 Java,这样有些人就不会理解可变结构体了。早期的 C# 允许对可变结构体值做一些愚蠢的事情,因此将结构体变成不可变的是阻止人们做这些愚蠢的事情的好方法。然而,如果编译器在只读上下文中禁止修改字段的尝试,而不是允许这样的修改但却无法起作用,那么在非只读上下文中禁止更新字段是没有任何收益的。实际上,我建议所谓的“不可变结构体”... - supercat
对于一个有经验的程序员来说,“裸露”的数据结构比“普通的旧数据”结构更容易出现问题。其中,只读上下文中的“可变”结构是不可变的,而非只读上下文中所谓的“不可变”结构是可变的。例如,KeyValuePair<T,U>ToString() 方法通过依次调用 KeyValueToString() 方法来工作。如果在 KeyValuePair<T,U> 调用 Key.ToString()Value.ToString() 之间被替换为新实例,则生成的字符串将把第一个的 Key 与第二个的 Value 结合起来。 - supercat

2

使用结构体作为32位或64位向量是合理的,但需要注意以下几点:

  1. 建议在对结构进行任何更新时都使用Interlocked.CompareExchange循环,而不是直接使用普通的布尔运算符。如果一个线程尝试写入第3位,而另一个线程尝试写入第8位,那么两个操作都不应该干扰对方,只会稍微延迟一下。使用Interlocked.CompareExchange循环将避免出现错误行为(线程1读取值,线程2读取旧值,线程1写入新值,线程2基于旧值计算并撤销线程1的更改)而无需任何其他类型的锁定。
  2. 应避免修改“this”的结构成员(除了属性设置器)。最好使用一个接受结构体作为引用参数的静态方法。调用修改“this”的结构成员通常与调用接受成员作为引用参数的静态方法在语义和性能上完全相同,但有一个关键的区别:如果尝试通过引用将只读结构传递给静态方法,则会收到编译器错误。相比之下,如果在只读结构上调用修改“this”的方法,就不会有任何编译器错误,但预期的修改也不会发生。由于即使是可变结构在某些上下文中也可能被视为只读,因此最好在出现这种情况时获得编译器错误,而不是有一个可以编译但无法正常工作的代码。

Eric Lippert喜欢抨击可变结构体是邪恶的,但需要认识到他与它们的关系:他是C#团队中负责使语言支持闭包和迭代器等功能的人之一。由于在创建.net早期的一些设计决策,正确支持值类型语义在某些上下文中很困难;如果没有可变值类型,他的工作就会更容易。我不怪Eric的观点,但需要注意的是,在框架和语言设计中可能很重要的一些原则对应用程序设计并不适用。


0
如果我理解的没错的话,你不能仅简单地使用 SerializableAttribute 来使结构体变成可序列化和不可变。这是因为在反序列化期间,序列化器会实例化结构体的默认实例,然后设置所有字段。如果它们是只读的,则反序列化将失败。
因此,该结构体必须是可变的,否则就需要采用复杂的序列化系统。

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