关于C#:为什么可变结构是”邪恶”的?

Why are mutable structs “evil”?

在这里讨论之后,我已经读了好几遍易变结构是"邪恶"的评论(就像这个问题的答案一样)。

C中的可变性和结构的实际问题是什么?


结构是值类型,这意味着它们在传递时被复制。

因此,如果您更改了一个副本,那么您只更改了那个副本,而不是原始副本,也不更改可能存在的任何其他副本。

如果您的结构是不可变的,那么由值传递产生的所有自动副本都将是相同的。

如果要更改它,必须有意识地使用修改后的数据创建结构的新实例。(不是复制品)


从哪里开始;-p

埃里克·利珀特的博客总是很适合引用:

This is yet another reason why mutable
value types are evil. Try to always
make value types immutable.

首先,你很容易丢失变化…例如,从列表中删除内容:

1
2
Foo foo = list[0];
foo.Name ="abc";

那改变了什么?没什么有用的…

属性相同:

1
myObj.SomeProperty.Size = 22; // the compiler spots this one

强迫你这样做:

1
2
3
Bar bar = myObj.SomeProperty;
bar.Size = 22;
myObj.SomeProperty = bar;

不太关键的是,存在大小问题;可变对象往往具有多个属性;但是,如果您有一个结构具有两个int、一个string、一个DateTime和一个bool,那么您可以很快地烧掉大量内存。对于一个类,多个调用方可以共享对同一实例的引用(引用很小)。


我不会说坏话,但易变性通常是程序员为了提供最大限度的功能而过度老化的一个标志。事实上,这通常是不需要的,而这反过来又使接口更小、更容易使用和更难使用错误(=更健壮)。

其中一个例子是在竞争条件下的读/写和写/写冲突。因为写操作不是有效的操作,所以不能在不可变的结构中发生。

另外,我声称实际上几乎不需要易变性,程序员只是认为它可能在未来。例如,改变一个日期是没有意义的。相反,创建一个基于旧日期的新日期。这是一个便宜的操作,所以性能不是一个考虑因素。


可变结构不是邪恶的。

它们在高性能环境下是绝对必要的。例如,当缓存线和/或垃圾收集成为瓶颈时。

在这些完全有效的用例中,我不会将不可变结构的使用称为"邪恶"。

我可以看出,C的语法对区分值类型或引用类型的成员的访问没有帮助,所以我完全赞成使用强制不可变的不可变结构,而不是可变结构。

然而,我建议不要简单地将不可变结构标记为"邪恶",而应采用这种语言,并提倡更有益和更具建设性的拇指规则。

例如:"结构是默认复制的值类型。如果你不想复制它们,你需要一个证明人"或者"首先尝试使用只读结构"。


具有公共可变字段或属性的结构不是邪恶的。好的。

结构方法(与属性设置器不同)会改变"this",这有点邪恶,只是因为.NET不提供将它们与不提供的方法区分开来的方法。不改变"this"的结构方法即使在只读结构上也应该可以调用,而不需要进行任何防御性复制。不应在只读结构上调用实现"this"变异的方法。由于.NET不希望禁止对只读结构调用不修改"this"的结构方法,但不希望对只读结构进行转换,因此它在只读上下文中防御性地复制结构,可以说这是两个领域中最糟糕的一个。好的。

尽管在只读上下文中处理自变异方法存在问题,但是可变结构通常提供远远优于可变类类型的语义。考虑以下三种方法签名:好的。

1
2
3
4
5
6
struct PointyStruct {public int x,y,z;};
class PointyClass {public int x,y,z;};

void Method1(PointyStruct foo);
void Method2(ref PointyStruct foo);
void Method3(PointyClass foo);

对于每种方法,回答以下问题:好的。

  • 假设该方法不使用任何"不安全"代码,它会修改foo吗?
  • 如果在调用方法之前不存在对"foo"的外部引用,那么在该方法之后是否存在外部引用?

    答案:好的。

    问题1:&emsp;Method1():否(明确意图)&emsp;Method2()是(明确意图)&emsp;Method3()是(意图不确定)问题2:&emsp;Method1()否&emsp;Method2()否(除非不安全)&emsp;Method3()是好的。< /块引用>

    方法1无法修改foo,并且从未获取引用。方法2获取一个对foo的短期引用,它可以使用修改foo的字段任意次数、任意顺序,直到返回,但它不能持久化该引用。在method2返回之前,除非它使用不安全的代码,否则可能由其"foo"引用生成的所有副本都将消失。方法3与方法2不同,它获取了一个可以随意共享的对foo的引用,并且不知道它会用它做什么。它可能根本不会更改foo,可能会更改foo然后返回,或者它可能会将foo引用给另一个线程,该线程可能在将来某个任意时间以某种任意方式改变foo。限制method3对传递给它的可变类对象所做的操作的唯一方法是将可变对象封装到一个只读包装器中,这个包装器既难看又麻烦。好的。

    结构数组提供了出色的语义。对于矩形类型的矩形[500],如何将元素123复制到元素456,然后在不干扰元素456的情况下,稍后将元素123的宽度设置为555,这是显而易见的。"矩形[432]=矩形[321];矩形[123];宽度=555;"。知道矩形是一个结构,它有一个称为width的整型字段,这将告诉我们所有人都需要知道上面的语句。好的。

    现在假设rectclass是一个具有与rectangle相同字段的类,并且其中一个类希望对rectclassarray[500]类型的rectclass执行相同的操作。也许数组应该包含500个对可变RectClass对象的预初始化不可变引用。在这种情况下,正确的代码应该是"rectclassarray[321].setbounds(rectclassarray[456]);…;rectclassarray[321].x=555;"。可能假定数组包含不会更改的实例,因此正确的代码更像是"rectclass array[321]=rectclass array[456]…";rectclass array[321]=new rectclass(rectclass array[321]);rectclass array[321].x=555;"要知道应该做什么,必须对rectclass(例如它是否支持复制构造函数、复制自方法等)以及数组的预期用途。没有比使用结构更干净的了。好的。

    当然,不幸的是,除了数组之外,没有更好的方法可以让任何容器类提供结构数组的干净语义。如果希望用字符串等对集合进行索引,最好的方法可能是提供一个通用的"actonitem"方法,该方法将接受索引的字符串、通用参数以及通过引用通用参数和集合项传递的委托。这将允许与结构数组几乎相同的语义,但除非可以追求vb.net和c_people来提供一个良好的语法,否则代码将是笨拙的,即使它具有合理的性能(传递一个通用参数将允许使用静态委托,并避免创建任何临时类实例)。好的。

    就我个人而言,我对埃里克·利珀特等人的仇恨感到恼火。有关可变值类型的spew。它们提供了比各地使用的混乱引用类型更清晰的语义。尽管.NET对值类型的支持有一些限制,但在许多情况下,可变值类型比任何其他类型的实体都更适合。好的。好啊。


    值类型基本上表示不变的概念。如果有一个数学值,比如一个整数、向量等,然后能够对其进行修改,这是没有意义的。这就像重新定义一个值的含义。与其更改值类型,不如指定另一个唯一值。请考虑这样一个事实,即通过比较其属性的所有值来比较值类型。关键是,如果属性相同,那么它就是该值的相同通用表示。

    正如Konrad提到的,更改日期也没有意义,因为值表示唯一的时间点,而不是具有任何状态或上下文依赖性的时间对象的实例。

    希望这对你有意义。当然,这更多的是关于您试图用值类型捕获的概念,而不是实际的细节。


    从程序员的角度来看,还有另外两个可能导致不可预测行为的角落案例。这两个。

  • 不可变值类型和只读字段
  • ZZU1〔0〕

  • 可变值类型和数组
  • 假设有一个可变结构的数组,我们正在为该数组的第一个元素调用incrementi方法。你对这个电话有什么期待?它应该更改数组的值还是只更改一个副本?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    Mutable[] arrayOfMutables = new Mutable[1];
    arrayOfMutables[0] = new Mutable(5);

    // Now we actually accessing reference to the first element
    // without making any additional copy
    arrayOfMutables[0].IncrementI();

    //Prints 6!!
    Console.WriteLine(arrayOfMutables[0].I);

    // Every array implements IList<T> interface
    IList<Mutable> listOfMutables = arrayOfMutables;

    // But accessing values through this interface lead
    // to different behavior: IList indexer returns a copy
    // instead of an managed reference
    listOfMutables[0].IncrementI(); // Should change I to 7

    // Nope! we still have 6, because previous line of code
    // mutate a copy instead of a list value
    Console.WriteLine(listOfMutables[0].I);

    所以,只要你和团队的其他成员清楚地了解你在做什么,可变结构就不是邪恶的。但是,当程序行为与预期行为不同时,会出现太多的角情况,这可能导致细微的难以产生和难以理解的错误。


    如果您曾经使用类似C/C++的语言编程,Struts很好地被用作可变的。只要把球传给裁判就行了,没有什么会出错的。我发现的唯一问题是C编译器的限制,在某些情况下,我无法强制愚蠢的事情使用对结构的引用,而不是副本(就像当结构是C类的一部分时)。

    因此,可变结构不是邪恶的,C使它们邪恶。我使用C++中的可变结构,它们非常方便直观。相反,C使我完全放弃了作为类成员的结构,因为它们处理对象的方式。他们的便利使我们付出了代价。


    假设您有一个由1000000个结构组成的数组。每一个结构代表一个股票,其内容包括出价、出价(可能是小数)等,这是由C/VB创建的。

    假设数组是在非托管堆中分配的内存块中创建的,这样其他一些本机代码线程就可以并发地访问数组(可能是一些执行数学运算的高性能代码)。

    假设C/VB代码正在监听价格变化的市场反馈,该代码可能需要访问数组的某些元素(对于任何一种安全性),然后修改一些价格字段。

    想象一下,这是每秒数万次甚至数十万次。

    好吧,让我们面对事实,在这种情况下,我们真的希望这些结构是可变的,它们需要是因为它们被其他一些本机代码共享,所以创建副本不会有帮助;它们需要是因为以这种速度复制大约120字节的结构是愚蠢的,特别是当更新可能只影响一两个字节时。

    雨果


    如果你坚持使用什么结构(在C语言中,Visual Basic 6,PASCAL/Delphi,C++结构体类型(或类)当它们不被用作指针)时,你会发现一个结构不只是一个复合变量。这意味着:您将把它们作为一组压缩的变量,用一个公共名称(引用成员的记录变量)来处理。

    我知道这会让很多习惯OOP的人感到困惑,但如果使用得当,这还不足以说明这些东西本质上是邪恶的。有些结构按照他们的意愿是不可变的(这是Python的EDCOX1〔0〕的情况),但它是另一个需要考虑的范例。

    是的:结构涉及大量的内存,但这样做并不能精确地增加内存:

    1
    point.x = point.x + 1

    相比:

    1
    point = Point(point.x + 1, point.y)

    在不可变的情况下,内存消耗至少是相同的,甚至更多(尽管对于当前堆栈,这种情况是临时的,取决于语言)。

    但最后,结构是结构,而不是物体。在poo中,对象的主要属性是它们的标识,大多数情况下,标识不超过其内存地址。struct代表数据结构(不是适当的对象,因此它们无论如何都没有标识),可以修改数据。在其他语言中,record(而不是struct,正如pascal的情况一样)是一个单词,具有相同的用途:只是一个数据记录变量,用于从文件中读取、修改和转储到文件中(这是主要用途,在许多语言中,您甚至可以在记录中定义数据对齐,但prop不一定如此erly调用了对象)。

    想要一个好的例子吗?结构用于轻松读取文件。Python有这个库,因为它是面向对象的,不支持结构,所以必须以另一种方式实现它,这有点难看。实现结构的语言具有此功能…内置的。尝试用Pascal或C等语言读取具有适当结构的位图头。这很容易(如果结构正确构建和对齐;在Pascal中,您不会使用基于记录的访问,而是使用函数读取任意二进制数据)。因此,对于文件和直接(本地)内存访问,结构比对象更好。至于今天,我们已经习惯了JSON和XML,因此我们忘记了使用二进制文件(作为副作用,也忘记了使用结构)。但是是的:它们存在,并且有目的。

    他们不是邪恶的。只是为了正确的目的使用它们。

    如果你从锤子的角度来思考,你会想把螺丝钉当作钉子来对待,发现螺丝钉更难插入墙中,这将是螺丝钉的错,他们将是邪恶的。


    当某物发生变异时,它会获得一种认同感。

    1
    2
    3
    4
    5
    6
    7
    8
    struct Person {
        public string name; // mutable
        public Point position = new Point(0, 0); // mutable

        public Person(string name, Point position) { ... }
    }

    Person eric = new Person("Eric Lippert", new Point(4, 2));

    因为Person是可变的,所以考虑改变埃里克的位置比克隆埃里克、移动克隆和破坏原始位置更自然。两种操作都可以成功地改变eric.position的内容,但一种操作比另一种操作更直观。同样,将Eric传递(作为参考)以获取修改他的方法更为直观。给一个方法克隆一个Eric几乎总是会令人惊讶的。任何想突变Person的人都必须记得向Person求助,否则他们会做错事。

    如果您使类型不可变,问题就消失了;如果我不能修改eric,那么我是否接收ericeric的克隆对我没有任何影响。更一般地说,如果一个类型的所有可观察状态都保存在以下成员中,则该类型可以安全地传递值:

    • 不变的
    • 引用类型
    • 安全通过值

    如果满足这些条件,则可变值类型的行为类似于引用类型,因为浅拷贝仍允许接收器修改原始数据。

    一个不变的Person的直观性取决于你想做什么。如果Person只是代表一组关于一个人的数据,那么它没有什么不合理的地方;Person变量真正代表抽象值,而不是对象。(在这种情况下,将其重命名为PersonData可能更合适。)如果Person实际上是在建模一个人本身,那么不断创建和移动克隆的想法是愚蠢的,即使你已经避免了认为你在修改原始版本的陷阱。在这种情况下,简单地将Person设置为引用类型(即类)可能更为自然。

    当然,正如函数式编程告诉我们的那样,使所有东西都不变是有好处的(没有人能秘密地保持对eric的引用并使其变异),但由于OOP中这不是惯用的,所以对于其他使用您的代码的人来说,这仍然是不可靠的。


    埃里克·利珀特先生的例子有几个问题。它旨在说明结构被复制的观点,以及如果不小心的话,这是如何成为一个问题的。看看这个例子,我认为它是一个糟糕的编程习惯的结果,而不是结构或类的真正问题。

  • 结构应该只有公共成员,不需要任何封装。如果是这样,那么它实际上应该是一个类型/类。你真的不需要两个结构来表达同一件事。

  • 如果类包含一个结构,您将调用类中的一个方法来改变成员结构。这是我作为一个好的编程习惯所要做的。

  • 正确的实现方法如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    struct Mutable {
    public int x;
    }

    class Test {
        private Mutable m = new Mutable();
        public int mutate()
        {
            m.x = m.x + 1;
            return m.x;
        }
      }
      static void Main(string[] args) {
            Test t = new Test();
            System.Console.WriteLine(t.mutate());
            System.Console.WriteLine(t.mutate());
            System.Console.WriteLine(t.mutate());
        }

    它看起来像是编程习惯的问题,而不是结构本身的问题。结构应该是可变的,这就是思想和意图。

    Voila的变化结果与预期一致:

    一二三按任意键继续。…


    它与结构没有任何关系(也不与C语言相关),但是在爪哇中,当它们是哈希映射中的键时,可能会遇到可变对象的问题。如果您在将它们添加到地图后更改它们,并且它更改了散列代码,那么可能会发生邪恶的事情。


    就我个人而言,当我看代码时,以下内容对我来说相当笨拙:

    data.value.set(data.value.get()+1);

    而不是简单的

    data.value++;或data.value=data.value+1;

    在传递类时,数据封装很有用,您希望确保以受控方式修改值。然而,当您拥有一个公共集合和一些函数,它们的作用仅仅是将值设置为曾经传入的值时,与简单地传递一个公共数据结构相比,这是一个怎样的改进呢?

    当我在一个类中创建一个私有结构时,我创建了这个结构来将一组变量组织成一个组。我希望能够在类范围内修改该结构,而不是获取该结构的副本并创建新的实例。

    对我来说,这会阻止有效地使用用于组织公共变量的结构,如果我想要访问控制,我会使用一个类。


    可变数据有许多优点和缺点。百万美元的劣势是化名。如果同一个值在多个地方被使用,并且其中一个地方更改了它,那么它将神奇地更改为其他使用它的地方。这与比赛条件有关,但不完全相同。

    百万美元的优势有时是模块化的。可变状态允许您隐藏不需要知道的代码中的更改信息。

    口译员的艺术在某些细节上涉及到这些权衡,并给出了一些例子。


    如果使用得当,我不相信它们是邪恶的。我不会把它放在我的生产代码中,但我会做一些类似于结构化单元测试模拟的事情,在这种情况下,结构的寿命相对较小。

    使用Eric示例,您可能希望创建该Eric的第二个实例,但要进行调整,因为这是测试的本质(即复制,然后修改)。如果我们只是在测试脚本的其余部分使用eric2,那么对于第一个eric实例会发生什么并不重要,除非您打算将其用作测试比较。

    这对于测试或修改Shallow定义特定对象(结构点)的遗留代码非常有用,但是通过使用不可变的结构,这可以防止使用它。