繁体   English   中英

为什么可变结构是“邪恶的”?

[英]Why are mutable structs “evil”?

在 SO 上的讨论之后,我已经多次阅读了关于可变结构是“邪恶的”的评论(就像在这个问题的答案中一样)。

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

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

因此,如果您更改副本,您只会更改该副本,而不是原件,也不会更改可能存在的任何其他副本。

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

如果你想改变它,你必须有意识地通过使用修改后的数据创建结构的新实例来进行。 (不是副本)

从哪里开始;-p

Eric Lippert 的博客总是很适合引用:

这是可变值类型是邪恶的另一个原因。 尝试始终使值类型不可变。

首先,您往往很容易丢失更改……例如,从列表中取出内容:

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

那有什么改变? 没什么用...

与属性相同:

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

强迫你做:

Bar bar = myObj.SomeProperty;
bar.Size = 22;
myObj.SomeProperty = bar;

不太重要的是,存在尺寸问题; 可变对象往往具有多个属性; 但是,如果您有一个包含两个int 、一个string 、一个DateTime和一个bool ,您会很快消耗大量内存。 对于一个类,多个调用者可以共享对同一个实例的引用(引用很小)。

我不会说邪恶,但可变性通常是程序员过度渴望提供最大功能的标志。 实际上,这通常是不需要的,这反过来会使界面更小、更易于使用且更难使用错误(= 更健壮)。

一个例子是竞争条件下的读/写和写/写冲突。 这些根本不能出现在不可变结构中,因为写入不是有效的操作。

另外,我声称几乎从来不需要可变性,程序员只是认为可能在未来。 例如,更改日期根本没有意义。 相反,根据旧日期创建一个新日期。 这是一个廉价的操作,所以性能不是一个考虑因素。

可变结构并不邪恶。

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

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

我可以看到,C#的语法不利于区分值类型或引用类型的成员的接入点,所以我所有喜欢一成不变的结构,即强制执行不变性,在可变的结构。

然而,我建议不要简单地将不可变结构标记为“邪恶”,而是建议接受该语言并提倡更有帮助和建设性的经验法则。

例如: “结构是值类型,默认情况下被复制。如果你不想复制它们,你需要一个引用”“尝试首先使用只读结构”

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

改变“this”的结构方法(与属性设置器不同)有点邪恶,只是因为.net没有提供将它们与没有的方法区分开来的方法。 不改变“this”的结构方法即使在只读结构上也应该是可调用的,而无需任何防御性复制。 改变“this”的方法根本不应该在只读结构上调用。 由于 .net 不想禁止不修改“this”的结构方法在只读结构上被调用,但不想允许只读结构发生变异,因此它防御性地复制只读结构只有上下文,可以说是两全其美。

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

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);

对于每种方法,请回答以下问题:

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

答案:

问题 1:
Method1() :否(意图明确)
Method2() : 是(意图明确)
Method3() : 是(不确定意图)
问题2:
Method1() : 没有
Method2() :否(除非不安全)
Method3() : 是

Method1 不能修改 foo,并且永远不会得到引用。 Method2 获得对 foo 的短期引用,它可以使用它以任何顺序修改 foo 的字段任意次数,直到它返回,但它不能保留该引用。 在 Method2 返回之前,除非它使用不安全的代码,否则任何和所有可能由其 'foo' 引用制成的副本都将消失。 与 Method2 不同的是,Method3 获得了一个对 foo 的可混杂共享的引用,并且不知道它可以用它做什么。 它可能根本不会改变 foo,它可能会改变 foo 然后返回,或者它可能会将 foo 的引用提供给另一个线程,该线程可能会在未来某个任意时间以某种任意方式改变它。 限制 Method3 可能对传递给它的可变类对象执行的操作的唯一方法是将可变对象封装到只读包装器中,这既丑陋又麻烦。

结构数组提供了美妙的语义。 给定 Rectangle 类型的 RectArray[500],很明显如何例如将元素 123 复制到元素 456,然后在一段时间后将元素 123 的宽度设置为 555,而不会干扰元素 456。“RectArray[432] = RectArray[321 ]; ...; RectArray[123].Width = 555;". 知道 Rectangle 是一个结构体,它有一个名为 Width 的整数字段,这将告诉人们所有需要了解的上述语句。

现在假设 RectClass 是一个与 Rectangle 具有相同字段的类,并且想要对 RectClass 类型的 RectClassArray[500] 执行相同的操作。 也许该数组应该保存 500 个对可变 RectClass 对象的预初始化的不可变引用。 在这种情况下,正确的代码将类似于“RectClassArray[321].SetBounds(RectClassArray[456]); ...; RectClassArray[321].X = 555;”。 也许假设数组包含不会改变的实例,所以正确的代码更像是“RectClassArray[321] = RectClassArray[456]; ...; RectClassArray[321] = New RectClass(RectClassArray[321] ]); RectClassArray[321].X = 555;" 要知道一个人应该做什么,就必须了解更多关于 RectClass(例如,它是否支持复制构造函数、复制自方法等)和数组的预期用途。 远不如使用结构那么干净。

可以肯定的是,不幸的是,除了数组之外的任何容器类都没有很好的方法来提供结构数组的清晰语义。 最好的方法是,如果想要使用例如字符串对集合进行索引,则可能是提供一个通用的“ActOnItem”方法,该方法将接受一个字符串作为索引、一个通用参数和一个将被传递的委托通过引用泛型参数和集合项。 这将允许与 struct 数组几乎相同的语义,但除非 vb.net 和 C# 人员可以提供良好的语法,否则即使代码具有合理的性能(传递泛型参数会允许使用静态委托,并且无需创建任何临时类实例)。

就个人而言,我对 Eric Lippert 等人的仇恨感到恼火。 关于可变值类型的喷涌。 与到处使用的混杂引用类型相比,它们提供了更清晰的语义。 尽管 .net 对值类型的支持存在一些限制,但在许多情况下,可变值类型比任何其他类型的实体都更适合。

从程序员的角度来看,还有一些其他极端情况可能会导致不可预测的行为。

不可变值类型和只读字段

    // Simple mutable structure. 
    // Method IncrementI mutates current state.
    struct Mutable
    {
        public Mutable(int i) : this() 
        {
            I = i;
        }

        public void IncrementI() { I++; }

        public int I { get; private set; }
    }

    // Simple class that contains Mutable structure
    // as readonly field
    class SomeClass 
    {
        public readonly Mutable mutable = new Mutable(5);
    }

    // Simple class that contains Mutable structure
    // as ordinary (non-readonly) field
    class AnotherClass 
    {
        public Mutable mutable = new Mutable(5);
    }

    class Program
    {
        void Main()
        {
            // Case 1. Mutable readonly field
            var someClass = new SomeClass();
            someClass.mutable.IncrementI();
            // still 5, not 6, because SomeClass.mutable field is readonly
            // and compiler creates temporary copy every time when you trying to
            // access this field
            Console.WriteLine(someClass.mutable.I);

            // Case 2. Mutable ordinary field
            var anotherClass = new AnotherClass();
            anotherClass.mutable.IncrementI();

            // Prints 6, because AnotherClass.mutable field is not readonly
            Console.WriteLine(anotherClass.mutable.I);
        }
    }

可变值类型和数组

假设我们有一个Mutable结构数组,并且我们正在为该数组的第一个元素调用IncrementI方法。 您期望此调用有什么行为? 它应该改变数组的值还是只改变一个副本?

    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);

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

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

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

希望这对你有意义。 可以肯定的是,它更多地是关于您尝试用值类型捕获的概念,而不是实际细节。

如果您曾经用 C/C++ 之类的语言进行过编程,那么结构体可以用作可变的。 只需将它们与 ref, around 一起传递,就不会出错。 我发现的唯一问题是 C# 编译器的限制,并且在某些情况下,我无法强制愚蠢的事情使用对结构的引用,而不是复制(就像当结构是 C# 类的一部分时)。

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

如果您坚持结构的用途(在 C#、Visual Basic 6、Pascal/Delphi、C++ 结构类型(或类)中,当它们不用作指针时),您会发现结构只不过是一个复合变量. 这意味着:您会将它们视为一组打包的变量,使用通用名称(您从中引用成员的记录变量)。

我知道这会让很多习惯于 OOP 的人感到困惑,但是如果使用得当,这不足以说明这些东西本质上是邪恶的。 有些结构是inmutable因为他们打算(这是Python的情况下namedtuple ),但它是另一种模式的考虑。

是的:结构涉及大量内存,但通过执行以下操作不会获得更多内存:

point.x = point.x + 1

相比:

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

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

但是,最后,结构是结构,而不是对象。 在 POO 中,对象的主要属性是其identity ,大多数情况下不超过其内存地址。 Struct 代表数据结构(不是一个合适的对象,所以它们无论如何都没有身份),并且数据可以被修改。 在其他语言中, record (而不是struct ,就像 Pascal 的情况一样)是这个词并且具有相同的目的:只是一个数据记录变量,旨在从文件中读取、修改和转储到文件中(这是主要的使用,并且在许多语言中,您甚至可以在记录中定义数据对齐,而正确调用对象的情况不一定如此)。

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

他们并不邪恶。 只需将它们用于正确的目的。

如果你用锤子来思考,你会想把螺丝当钉子,发现螺丝更难插在墙上,是螺丝的错,是坏的。

假设您有一个包含 1,000,000 个结构的数组。 每个结构都代表一个股权,其中包含bid_price、offer_price(可能是小数)等,这是由C#/VB 创建的。

想象一下,数组是在非托管堆中分配的内存块中创建的,以便其他一些本机代码线程能够并发访问该数组(也许是一些高性能代码进行数学运算)。

想象一下,C#/VB 代码正在侦听价格变化的市场馈送,该代码可能必须访问数组的某些元素(无论哪种安全),然后修改某些价格字段。

想象一下,每秒执行数万次甚至数十万次。

好吧,让我们面对事实,在这种情况下,我们确实希望这些结构是可变的,它们需要是可变的,因为它们被其他一些本机代码共享,因此创建副本无济于事; 他们需要这样做,因为以这些速率复制一些 120 字节的结构是疯狂的,尤其是当更新实际上可能只影响一两个字节时。

雨果

当某些东西可以变异时,它就会获得一种认同感。

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 的位置比克隆 Eric、移动克隆和破坏原始. 这两种操作都可以成功更改eric.position的内容,但一种比另一种更直观。 同样,将 Eric 传递给(作为参考)用于修改他的方法更直观。 给一个方法一个 Eric 的克隆几乎总是会令人惊讶。 任何想要改变Person必须记住要求对Person的引用,否则他们会做错事。

如果您使类型不可变,问题就会消失; 如果我不能修改eric ,这都没有区别,以我是否收到eric或克隆eric 更一般地说,如果一个类型的所有可观察状态都保存在以下任一成员中,则该类型可以安全地按值传递:

  • 不可变的
  • 引用类型
  • 按值传递是安全的

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

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

诚然,正如函数式编程告诉我们的那样,让一切都不可变是有好处的(没有人可以秘密地持有对eric的引用并改变他),但是由于这在 OOP 中不是惯用的,因此对于其他使用它的人来说仍然是不直观的你的代码。

Eric Lippert 先生的例子有几个问题。 人为地说明了复制结构的观点,以及如果您不小心,这将如何成为问题。 看这个例子,我认为这是一个糟糕的编程习惯的结果,而不是结构或类的真正问题。

  1. 结构应该只有公共成员,并且不需要任何封装。 如果是,那么它真的应该是一个类型/类。 你真的不需要两个结构来表达同样的事情。

  2. 如果你有一个封闭结构的类,你会调用类中的一个方法来改变成员结构。 这就是我会做的一个良好的编程习惯。

正确的实现如下。

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 的行为符合预期:

1 2 3 按任意键继续。 . .

它与结构无关(也与 C# 无关),但在 Java 中,当可变对象是哈希映射中的键时,您可能会遇到可变对象的问题。 如果在将它们添加到地图后更改它们并更改其哈希码,则可能会发生邪恶的事情。

可变数据有很多优点和缺点。 百万美元的劣势是混叠。 如果在多个地方使用相同的值,并且其中一个更改了它,那么它似乎已经神奇地更改为其他正在使用它的地方。 这与竞争条件有关,但不完全相同。

有时,百万美元的优势是模块化。 可变状态可以让你隐藏代码中不需要知道的变化信息。

The Art of the Interpreter详细介绍了这些权衡,并给出了一些示例。

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

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

而不是简单地

数据值++; 或 data.value = data.value + 1 ;

传递类时,数据封装很有用,并且您希望确保以受控方式修改值。 但是,当您拥有公共 set 和 get 函数时,它们所做的只是将值设置为传入的内容,与简单地传递公共数据结构相比,这有何改进?

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

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

如果使用得当,我不相信它们是邪恶的。 我不会把它放在我的生产代码中,但我会像结构化单元测试模拟这样的东西,其中结构的生命周期相对较短。

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

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM